Skip to content

Commit 45bdef4

Browse files
authored
Merge pull request #256 from esokullu/main
bugfix with video record with no sound
2 parents e0e4033 + bc8a188 commit 45bdef4

10 files changed

Lines changed: 125 additions & 59 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "webbrain",
3-
"version": "20.1.1",
3+
"version": "20.1.3",
44
"description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.",
55
"private": true,
66
"type": "module",

src/chrome/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# WebBrain Chrome/Edge Extension — Architecture
22

3-
> Version 20.1.1 · Manifest V3 · Service Worker background
3+
> Version 20.1.3 · Manifest V3 · Service Worker background
44
55
## High-Level Overview
66

@@ -192,8 +192,8 @@ offscreen/recorder.js
192192
│ captured audio ─→ mixDestination
193193
│ mic ─→ mixDestination
194194
│ tab audio only ─→ audioContext.destination (passthrough so user hears the call)
195-
├─ MediaStream(video tracks ∪ mixDestination audio tracks)
196-
└─ MediaRecorder(mimeType: 'video/webm;codecs=vp9,opus')
195+
├─ MediaStream(video tracks ∪ mixDestination audio tracks when audio exists)
196+
└─ MediaRecorder(mimeType selected from actual final video/audio tracks)
197197
└─ ondataavailable → chunks[]
198198
→ on stop, Blob → dataURL → background
199199

src/chrome/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "WebBrain",
4-
"version": "20.1.1",
4+
"version": "20.1.3",
55
"description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.",
66
"permissions": [
77
"sidePanel",

src/chrome/src/offscreen/recorder.js

Lines changed: 107 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@
5353
});
5454
}
5555

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+
5682
async function start(message) {
5783
let { streamId, tabId, options } = message || {};
5884
if (session) {
@@ -74,26 +100,6 @@
74100
mimeType: requestedMime,
75101
} = options || {};
76102

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-
97103
// 1. Capture stream — chrome.tabCapture exposes the active tab as a
98104
// MediaStream when we pass the streamId we got from
99105
// chrome.tabCapture.getMediaStreamId() on the service-worker side. For
@@ -172,41 +178,63 @@
172178
// them. Release everything we acquired before rethrowing.
173179
let audioContext = null;
174180
let recorder;
181+
let chosenMime;
175182
try {
176-
audioContext = new AudioContext();
177-
const mixDest = audioContext.createMediaStreamDestination();
178-
179183
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+
}
187198
}
188-
}
189199

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+
}
193204
}
194205

195206
// 4. Build the final stream the recorder consumes.
196207
const finalStream = new MediaStream();
197208
if (video) {
198209
for (const t of captureStream.getVideoTracks()) finalStream.addTrack(t);
199210
}
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+
});
201224

202225
// 5. MediaRecorder. Collect dataavailable chunks. Pass a timeslice so
203226
// partial data survives a crash and gives us progress estimates.
204-
recorder = new MediaRecorder(finalStream, {
227+
const recorderOptions = {
205228
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);
210238
} catch (e) {
211239
try { for (const t of captureStream.getTracks()) t.stop(); } catch {}
212240
if (micStream) { try { for (const t of micStream.getTracks()) t.stop(); } catch {} }
@@ -215,7 +243,9 @@
215243
}
216244
const chunks = [];
217245
let bytes = 0;
246+
let dataEventCount = 0;
218247
recorder.ondataavailable = (e) => {
248+
dataEventCount += 1;
219249
if (e.data && e.data.size > 0) {
220250
chunks.push(e.data);
221251
bytes += e.data.size;
@@ -241,6 +271,7 @@
241271
stopPromise: null,
242272
stopEventObserved: false,
243273
get bytes() { return bytes; },
274+
get dataEventCount() { return dataEventCount; },
244275
};
245276
const activeSession = session;
246277
recorder.addEventListener('stop', () => {
@@ -339,30 +370,59 @@
339370
function waitForRecorderStop(s) {
340371
if (!s?.recorder) return Promise.resolve();
341372
if (s.stopPromise) return s.stopPromise;
342-
if (s.recorder.state === 'inactive' && s.stopEventObserved) return Promise.resolve();
343373
s.stopPromise = new Promise((resolve) => {
344374
let done = false;
345375
let timeout = null;
376+
let stopped = s.recorder.state === 'inactive';
377+
let finalDataSettled = false;
378+
const initialBytes = Number(s.bytes || 0);
346379
const finish = () => {
347380
if (done) return;
348381
done = true;
349382
s.stopEventObserved = true;
350383
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 {}
352386
resolve();
353387
};
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 {}
355408
try { s.recorder.requestData(); } catch {}
356409
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;
358417
finish();
359-
}, 5000);
418+
}, 2000);
360419
try {
361-
if (s.recorder.state === 'inactive') finish();
420+
if (s.recorder.state === 'inactive') stopped = true;
362421
else s.recorder.stop();
363422
} catch {
364-
finish();
423+
stopped = true;
365424
}
425+
maybeFinish();
366426
});
367427
return s.stopPromise;
368428
}

src/chrome/src/ui/settings.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919

2020
// Version shown in the subtitle. Kept here so it only needs one update per
2121
// release; the subtitle string itself is translated.
22-
const EXT_VERSION = '20.1.1';
22+
const EXT_VERSION = '20.1.3';
2323

2424
const providersContainer = document.getElementById('providers');
2525
const displaySettings = document.getElementById('display-settings');

src/firefox/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# WebBrain Firefox Extension — Architecture
22

3-
> Version 20.1.1 · Manifest V2 · Background Page
3+
> Version 20.1.3 · Manifest V2 · Background Page
44
55
## How Firefox Differs from Chrome
66

src/firefox/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 2,
33
"name": "WebBrain",
4-
"version": "20.1.1",
4+
"version": "20.1.3",
55
"description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.",
66
"permissions": [
77
"activeTab",

src/firefox/src/ui/settings.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919

2020
// Version shown in the subtitle. Kept here so it only needs one update per
2121
// release; the subtitle string itself is translated.
22-
const EXT_VERSION = '20.1.1';
22+
const EXT_VERSION = '20.1.3';
2323

2424
const providersContainer = document.getElementById('providers');
2525
const displaySettings = document.getElementById('display-settings');

test/run.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4857,11 +4857,17 @@ test('chrome /record-full-screen is slash-only, Chrome-only, and hidden from the
48574857
assert.match(host, /export async function startDisplayRecording\(options = \{\}/, 'chrome: recorder host should expose display recording for slash command routing');
48584858
assert.match(host, /function scheduleRecordingSafetyWatchdog[\s\S]*?chrome\.alarms\?\.create/, 'chrome: hidden recordings should have a background-owned safety cap');
48594859
assert.match(offscreen, /navigator\.mediaDevices\.getDisplayMedia\(\{[\s\S]*?audio: audio !== false,[\s\S]*?video: true,[\s\S]*?\}\)/, 'chrome: offscreen recorder should open the screen/window picker through getDisplayMedia');
4860+
assert.match(offscreen, /function pickRecorderMimeType\(\{ hasVideo, hasAudio, requestedMime \}\)[\s\S]*?'video\/webm;codecs=vp9,opus'[\s\S]*?'video\/webm;codecs=vp9'[\s\S]*?'audio\/webm;codecs=opus'/, 'chrome: recorder should choose MIME after it knows whether the final stream has audio');
4861+
assert.match(offscreen, /const hasAudioSources = capturedAudioTracks\.length > 0 \|\| !!micStream;[\s\S]*?if \(hasAudioSources\) \{[\s\S]*?mixDest = audioContext\.createMediaStreamDestination\(\);[\s\S]*?if \(mixDest\) \{[\s\S]*?mixDest\.stream\.getAudioTracks\(\)/, 'chrome: display video-only recordings should not add an empty WebAudio track');
4862+
assert.match(offscreen, /if \(finalHasAudio\) \{[\s\S]*?recorderOptions\.audioBitsPerSecond = 192_000;[\s\S]*?\}[\s\S]*?if \(finalHasVideo\) \{[\s\S]*?recorderOptions\.videoBitsPerSecond = 2_500_000;/, 'chrome: recorder bitrate hints should match the actual final stream tracks');
48604863
assert.doesNotMatch(offscreen, /chrome\.desktopCapture/, 'chrome: offscreen recorder cannot use extension APIs beyond chrome.runtime');
48614864
assert.match(offscreen, /addEventListener\('ended', \(\) => \{[\s\S]*?s\.captureEndedCleanupStarted = true;[\s\S]*?finalizeCaptureEnded\(s\)\.catch/, 'chrome: capture-ended cleanup should use the async finalize path');
48624865
assert.match(offscreen, /async function finalizeCaptureEnded\(s\) \{[\s\S]*?s\.stopping = true;[\s\S]*?await waitForRecorderStop\(s\);[\s\S]*?notifyCaptureEnded\(s\);[\s\S]*?await releaseSession\(s\);[\s\S]*?\}/, 'chrome: capture-ended finalize should wait for final recorder data before notifying background');
48634866
assert.match(offscreen, /function waitForRecorderStop\(s\) \{[\s\S]*?if \(s\.stopPromise\) return s\.stopPromise;[\s\S]*?s\.stopPromise = new Promise/, 'chrome: recorder stop waits should share one in-progress stop promise');
4864-
assert.doesNotMatch(offscreen, /s\.recorder\.state === 'inactive'\) return Promise\.resolve/, 'chrome: inactive recorder state alone must not skip waiting for queued stop/dataavailable events');
4867+
assert.match(offscreen, /let dataEventCount = 0;[\s\S]*?recorder\.ondataavailable = \(e\) => \{[\s\S]*?dataEventCount \+= 1;[\s\S]*?get dataEventCount\(\) \{ return dataEventCount; \}/, 'chrome: recorder should track dataavailable events for finalization');
4868+
assert.match(offscreen, /const onFinalDataAvailable = \(e\) => \{[\s\S]*?finalDataSettled = true;[\s\S]*?maybeFinish\(\);[\s\S]*?\};[\s\S]*?s\.recorder\.addEventListener\('dataavailable', onFinalDataAvailable\)/, 'chrome: recorder stop must wait for final dataavailable before resolving');
4869+
assert.match(offscreen, /timed out waiting for MediaRecorder final data[\s\S]*?\}, 2000\);/, 'chrome: final data fallback should stay short enough for visible stop UI');
4870+
assert.doesNotMatch(offscreen, /s\.recorder\.state === 'inactive'\) (?:return Promise\.resolve|finish\(\))/, 'chrome: inactive recorder state alone must not skip waiting for queued stop/dataavailable events');
48654871
assert.match(offscreen, /function notifyCaptureEnded\(s\) \{[\s\S]*?target: 'background'[\s\S]*?action: 'recording_capture_ended'[\s\S]*?tabId: s\.tabId/, 'chrome: capture-ended notify should ask background to persist the stopped recording');
48664872
assert.match(background, /case 'recording_capture_ended':[\s\S]*?stopTabRecording\(\{ reason: 'capture_ended' \}\)/, 'chrome: background should finalize and save recordings when the shared stream ends');
48674873
assert.match(offscreen, /async function releaseSession\(s\) \{[\s\S]*?s\.captureStream = null;[\s\S]*?s\.micStream = null;[\s\S]*?s\.audioContext = null;[\s\S]*?micStream\?\.getTracks/, 'chrome: releaseSession should be idempotent and stop the separately-acquired mic stream');

0 commit comments

Comments
 (0)