返回 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 if (!streamCamera.value) {
110 if (currentCamera.value === 'none' && currentMic.value === 'none')
111 return
112
113 streamCamera.value = await navigator.mediaDevices.getUserMedia({
114 video: (currentCamera.value === 'none' || recordCamera.value !== true)
115 ? false
116 : {
117 deviceId: currentCamera.value,
118 },
119 audio: currentMic.value === 'none'
120 ? false
121 : {
122 deviceId: currentMic.value,
123 },
124 })
125 }
126 }
127
128 watch(currentCamera, async (v) => {
129 if (v === 'none') {
130 closeStream(streamCamera)
131 }
132 else {
133 if (recording.value)
134 return
135 // restart camera stream
136 if (streamCamera.value) {
137 closeStream(streamCamera)
138 await startCameraStream()
139 }
140 }
141 })
142
143 async function startRecording(customConfig?: RecorderOptions) {
144 await ensureDevicesListPermissions()
145 const { default: Recorder } = await import('recordrtc')
146 await startCameraStream()
147
148 const [width, height] = resolution.value.split('x').map(Number)
149 streamCapture.value = await navigator.mediaDevices.getDisplayMedia({
150 video: {
151 // aspectRatio: 1.6,
152 frameRate: frameRate.value,
153 width,
154 height,
155 // @ts-expect-error missing types
156 cursor: 'motion',
157 resizeMode: 'crop-and-scale',
158 },
159 selfBrowserSurface: 'include',
160 })
161 streamCapture.value.addEventListener('inactive', stopRecording)
162
163 // We need to create a new Stream to merge video and audio to have the inactive event working on streamCapture
164 streamSlides.value = new MediaStream()
165 streamCapture.value!.getVideoTracks().forEach(videoTrack => streamSlides.value!.addTrack(videoTrack))
166
167 // merge config
168 Object.assign(config, customConfig)
169
170 if (streamCamera.value) {
171 const audioTrack = streamCamera.value!.getAudioTracks()?.[0]
172 if (audioTrack)
173 streamSlides.value!.addTrack(audioTrack)
174
175 recorderCamera.value = new Recorder(
176 streamCamera.value!,
177 config,
178 )
179 recorderCamera.value.startRecording()
180 }
181
182 recorderSlides.value = new Recorder(
183 streamSlides.value!,
184 config,
185 )
186
187 recorderSlides.value.startRecording()
188 recordingStartTime = Date.now()
189 recording.value = true
190 }
191
192 async function stopRecording() {
193 recording.value = false
194 const duration = Date.now() - recordingStartTime
195
196 recorderCamera.value?.stopRecording(() => {
197 if (recordCamera.value) {
198 const blob = recorderCamera.value!.getBlob()
199 downloadBlob(blob, duration, getFilename('camera', config.mimeType))
200 }
201 recorderCamera.value = undefined
202 if (!showAvatar.value)
203 closeStream(streamCamera)
204 })
205 recorderSlides.value?.stopRecording(() => {
206 const blob = recorderSlides.value!.getBlob()
207 downloadBlob(blob, duration, getFilename('screen', config.mimeType))
208 closeStream(streamCapture)
209 closeStream(streamSlides)
210 recorderSlides.value = undefined
211 })
212 }
213
214 async function downloadBlob(blob: Blob, duration: number, filename: string) {
215 const fixedBlob = await fixWebmDuration(blob, duration, { logger: false })
216 const url = URL.createObjectURL(fixedBlob)
217 download(filename, url)
218 window.URL.revokeObjectURL(url)
219 }
220
221 function closeStream(stream: Ref<MediaStream | undefined>) {
222 const s = stream.value
223 if (!s)
224 return
225 s.getTracks().forEach((i) => {
226 i.stop()
227 s.removeTrack(i)
228 })
229 stream.value = undefined
230 }
231
232 function toggleRecording() {
233 if (recording.value)
234 stopRecording()
235 else
236 startRecording()
237 }
238
239 useEventListener('beforeunload', (event) => {
240 if (!recording.value)
241 return
242 // eslint-disable-next-line no-alert
243 if (confirm('Recording is not saved yet, do you want to leave?'))
244 return
245 event.preventDefault()
246 event.returnValue = ''
247 })
248
249 return {
250 recording,
251 showAvatar,
252 toggleRecording,
253 startRecording,
254 stopRecording,
255 toggleAvatar,
256 recorderCamera,
257 recorderSlides,
258 streamCamera,
259 streamCapture,
260 streamSlides,
261 }
262 }
263
264 export const recorder = useRecording()
265
265 lines TYPESCRIPT