|
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 {} } |
|
215 | 243 | } |
216 | 244 | const chunks = []; |
217 | 245 | let bytes = 0; |
| 246 | + let dataEventCount = 0; |
218 | 247 | recorder.ondataavailable = (e) => { |
| 248 | + dataEventCount += 1; |
219 | 249 | if (e.data && e.data.size > 0) { |
220 | 250 | chunks.push(e.data); |
221 | 251 | bytes += e.data.size; |
|
241 | 271 | stopPromise: null, |
242 | 272 | stopEventObserved: false, |
243 | 273 | get bytes() { return bytes; }, |
| 274 | + get dataEventCount() { return dataEventCount; }, |
244 | 275 | }; |
245 | 276 | const activeSession = session; |
246 | 277 | recorder.addEventListener('stop', () => { |
|
339 | 370 | function waitForRecorderStop(s) { |
340 | 371 | if (!s?.recorder) return Promise.resolve(); |
341 | 372 | if (s.stopPromise) return s.stopPromise; |
342 | | - if (s.recorder.state === 'inactive' && s.stopEventObserved) return Promise.resolve(); |
343 | 373 | s.stopPromise = new Promise((resolve) => { |
344 | 374 | let done = false; |
345 | 375 | let timeout = null; |
| 376 | + let stopped = s.recorder.state === 'inactive'; |
| 377 | + let finalDataSettled = false; |
| 378 | + const initialBytes = Number(s.bytes || 0); |
346 | 379 | const finish = () => { |
347 | 380 | if (done) return; |
348 | 381 | done = true; |
349 | 382 | s.stopEventObserved = true; |
350 | 383 | if (timeout) clearTimeout(timeout); |
351 | | - try { s.recorder.removeEventListener('stop', finish); } catch {} |
| 384 | + try { s.recorder.removeEventListener('stop', onStop); } catch {} |
| 385 | + try { s.recorder.removeEventListener('dataavailable', onFinalDataAvailable); } catch {} |
352 | 386 | resolve(); |
353 | 387 | }; |
354 | | - try { s.recorder.addEventListener('stop', finish); } catch {} |
| 388 | + const maybeFinish = () => { |
| 389 | + if (stopped && finalDataSettled) finish(); |
| 390 | + }; |
| 391 | + const onStop = () => { |
| 392 | + stopped = true; |
| 393 | + maybeFinish(); |
| 394 | + }; |
| 395 | + const onFinalDataAvailable = (e) => { |
| 396 | + if (e?.data && e.data.size > 0) { |
| 397 | + finalDataSettled = true; |
| 398 | + } else if (initialBytes > 0 || Number(s.bytes || 0) > 0) { |
| 399 | + // A final dataavailable event can be empty when prior timeslices |
| 400 | + // already flushed usable bytes. Treat that as settled, but never |
| 401 | + // accept a zero-byte recording until the timeout fallback fires. |
| 402 | + finalDataSettled = true; |
| 403 | + } |
| 404 | + maybeFinish(); |
| 405 | + }; |
| 406 | + try { s.recorder.addEventListener('stop', onStop); } catch {} |
| 407 | + try { s.recorder.addEventListener('dataavailable', onFinalDataAvailable); } catch {} |
355 | 408 | try { s.recorder.requestData(); } catch {} |
356 | 409 | timeout = setTimeout(() => { |
357 | | - log('timed out waiting for MediaRecorder stop event; finalizing with collected chunks'); |
| 410 | + log('timed out waiting for MediaRecorder final data; finalizing with collected chunks', { |
| 411 | + bytes: s.bytes, |
| 412 | + dataEvents: s.dataEventCount, |
| 413 | + stopped, |
| 414 | + }); |
| 415 | + stopped = true; |
| 416 | + finalDataSettled = true; |
358 | 417 | finish(); |
359 | | - }, 5000); |
| 418 | + }, 2000); |
360 | 419 | try { |
361 | | - if (s.recorder.state === 'inactive') finish(); |
| 420 | + if (s.recorder.state === 'inactive') stopped = true; |
362 | 421 | else s.recorder.stop(); |
363 | 422 | } catch { |
364 | | - finish(); |
| 423 | + stopped = true; |
365 | 424 | } |
| 425 | + maybeFinish(); |
366 | 426 | }); |
367 | 427 | return s.stopPromise; |
368 | 428 | } |
|
0 commit comments