|
53 | 53 | }); |
54 | 54 | } |
55 | 55 |
|
| 56 | + function pickRecorderMimeType({ hasVideo, hasAudio, requestedMime }) { |
| 57 | + const candidates = hasVideo |
| 58 | + ? (hasAudio |
| 59 | + ? [ |
| 60 | + 'video/webm;codecs=vp9,opus', |
| 61 | + 'video/webm;codecs=vp8,opus', |
| 62 | + 'video/webm', |
| 63 | + ] |
| 64 | + : [ |
| 65 | + 'video/webm;codecs=vp9', |
| 66 | + 'video/webm;codecs=vp8', |
| 67 | + 'video/webm', |
| 68 | + ]) |
| 69 | + : [ |
| 70 | + 'audio/webm;codecs=opus', |
| 71 | + 'audio/webm', |
| 72 | + ]; |
| 73 | + const chosenMime = (requestedMime && MediaRecorder.isTypeSupported(requestedMime)) |
| 74 | + ? requestedMime |
| 75 | + : candidates.find(m => MediaRecorder.isTypeSupported(m)); |
| 76 | + if (!chosenMime) { |
| 77 | + throw new Error('No supported MediaRecorder mimeType found.'); |
| 78 | + } |
| 79 | + return chosenMime; |
| 80 | + } |
| 81 | + |
56 | 82 | async function start(message) { |
57 | 83 | let { streamId, tabId, options } = message || {}; |
58 | 84 | if (session) { |
|
74 | 100 | mimeType: requestedMime, |
75 | 101 | } = options || {}; |
76 | 102 |
|
77 | | - // Pick a MediaRecorder mimeType the browser actually supports. VP9 is |
78 | | - // best quality-per-byte; VP8 is the wide-compat fallback. Audio-only |
79 | | - // gets webm/opus. |
80 | | - const candidates = video |
81 | | - ? [ |
82 | | - 'video/webm;codecs=vp9,opus', |
83 | | - 'video/webm;codecs=vp8,opus', |
84 | | - 'video/webm', |
85 | | - ] |
86 | | - : [ |
87 | | - 'audio/webm;codecs=opus', |
88 | | - 'audio/webm', |
89 | | - ]; |
90 | | - const chosenMime = (requestedMime && MediaRecorder.isTypeSupported(requestedMime)) |
91 | | - ? requestedMime |
92 | | - : candidates.find(m => MediaRecorder.isTypeSupported(m)); |
93 | | - if (!chosenMime) { |
94 | | - throw new Error('No supported MediaRecorder mimeType found.'); |
95 | | - } |
96 | | - |
97 | 103 | // 1. Capture stream — chrome.tabCapture exposes the active tab as a |
98 | 104 | // MediaStream when we pass the streamId we got from |
99 | 105 | // chrome.tabCapture.getMediaStreamId() on the service-worker side. For |
|
172 | 178 | // them. Release everything we acquired before rethrowing. |
173 | 179 | let audioContext = null; |
174 | 180 | let recorder; |
| 181 | + let chosenMime; |
175 | 182 | try { |
176 | | - audioContext = new AudioContext(); |
177 | | - const mixDest = audioContext.createMediaStreamDestination(); |
178 | | - |
179 | 183 | const capturedAudioTracks = captureStream.getAudioTracks(); |
180 | | - if (capturedAudioTracks.length) { |
181 | | - const capturedAudioSource = audioContext.createMediaStreamSource( |
182 | | - new MediaStream(capturedAudioTracks) |
183 | | - ); |
184 | | - capturedAudioSource.connect(mixDest); // into the recording |
185 | | - if (source === 'tab') { |
186 | | - capturedAudioSource.connect(audioContext.destination); |
| 184 | + const hasAudioSources = capturedAudioTracks.length > 0 || !!micStream; |
| 185 | + let mixDest = null; |
| 186 | + if (hasAudioSources) { |
| 187 | + audioContext = new AudioContext(); |
| 188 | + mixDest = audioContext.createMediaStreamDestination(); |
| 189 | + |
| 190 | + if (capturedAudioTracks.length) { |
| 191 | + const capturedAudioSource = audioContext.createMediaStreamSource( |
| 192 | + new MediaStream(capturedAudioTracks) |
| 193 | + ); |
| 194 | + capturedAudioSource.connect(mixDest); // into the recording |
| 195 | + if (source === 'tab') { |
| 196 | + capturedAudioSource.connect(audioContext.destination); |
| 197 | + } |
187 | 198 | } |
188 | | - } |
189 | 199 |
|
190 | | - if (micStream) { |
191 | | - const micSource = audioContext.createMediaStreamSource(micStream); |
192 | | - micSource.connect(mixDest); // into the recording (do NOT loop to speaker — feedback) |
| 200 | + if (micStream) { |
| 201 | + const micSource = audioContext.createMediaStreamSource(micStream); |
| 202 | + micSource.connect(mixDest); // into the recording (do NOT loop to speaker — feedback) |
| 203 | + } |
193 | 204 | } |
194 | 205 |
|
195 | 206 | // 4. Build the final stream the recorder consumes. |
196 | 207 | const finalStream = new MediaStream(); |
197 | 208 | if (video) { |
198 | 209 | for (const t of captureStream.getVideoTracks()) finalStream.addTrack(t); |
199 | 210 | } |
200 | | - for (const t of mixDest.stream.getAudioTracks()) finalStream.addTrack(t); |
| 211 | + if (mixDest) { |
| 212 | + for (const t of mixDest.stream.getAudioTracks()) finalStream.addTrack(t); |
| 213 | + } |
| 214 | + const finalHasVideo = finalStream.getVideoTracks().length > 0; |
| 215 | + const finalHasAudio = finalStream.getAudioTracks().length > 0; |
| 216 | + if (!finalHasVideo && !finalHasAudio) { |
| 217 | + throw new Error('No media tracks available to record.'); |
| 218 | + } |
| 219 | + chosenMime = pickRecorderMimeType({ |
| 220 | + hasVideo: finalHasVideo, |
| 221 | + hasAudio: finalHasAudio, |
| 222 | + requestedMime, |
| 223 | + }); |
201 | 224 |
|
202 | 225 | // 5. MediaRecorder. Collect dataavailable chunks. Pass a timeslice so |
203 | 226 | // partial data survives a crash and gives us progress estimates. |
204 | | - recorder = new MediaRecorder(finalStream, { |
| 227 | + const recorderOptions = { |
205 | 228 | mimeType: chosenMime, |
206 | | - // ~256 kbps audio is plenty for speech; the rest is video budget. |
207 | | - audioBitsPerSecond: 192_000, |
208 | | - videoBitsPerSecond: 2_500_000, |
209 | | - }); |
| 229 | + }; |
| 230 | + if (finalHasAudio) { |
| 231 | + // ~192 kbps audio is plenty for speech; the rest is video budget. |
| 232 | + recorderOptions.audioBitsPerSecond = 192_000; |
| 233 | + } |
| 234 | + if (finalHasVideo) { |
| 235 | + recorderOptions.videoBitsPerSecond = 2_500_000; |
| 236 | + } |
| 237 | + recorder = new MediaRecorder(finalStream, recorderOptions); |
210 | 238 | } catch (e) { |
211 | 239 | try { for (const t of captureStream.getTracks()) t.stop(); } catch {} |
212 | 240 | if (micStream) { try { for (const t of micStream.getTracks()) t.stop(); } catch {} } |
|
0 commit comments