Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e4bd2ac
fix(onboarding,billing): surface completeAndExit errors and suppress …
YellowSnnowmann Jun 3, 2026
09d7be9
test(onboarding): add RuntimeChoicePage unit tests for coverage gate
YellowSnnowmann Jun 3, 2026
5417bee
style: apply prettier formatting to RuntimeChoicePage.test.tsx
YellowSnnowmann Jun 3, 2026
6130121
feat(agent_meetings): forward agentName, systemPrompt, riveColors thr…
YellowSnnowmann Jun 3, 2026
4927953
feat(agent_meetings): forward agentName, systemPrompt, riveColors to …
YellowSnnowmann Jun 3, 2026
b9b636f
fix(auth): add ngrok-skip-browser-warning header to health check and …
YellowSnnowmann Jun 3, 2026
daecb47
feat(agent_meetings): add mascotId wiring, fix tests, revert red back…
YellowSnnowmann Jun 4, 2026
cd0de16
fix(agent_meetings): address CodeRabbit review comments
YellowSnnowmann Jun 4, 2026
3b8ab46
fix(tests): register backendMeet reducer in test store; add matchMedi…
YellowSnnowmann Jun 4, 2026
5c4a742
test(coverage): add ActiveMeetingView + MascotFrameProducer tests to …
YellowSnnowmann Jun 4, 2026
7481bf2
test(coverage): export sampleCanvasPixels and add unit tests
YellowSnnowmann Jun 4, 2026
ce820bd
style: run prettier on MascotFrameProducer files
YellowSnnowmann Jun 4, 2026
0378992
fix(test): remove invalid eslint-disable for renamed rule
YellowSnnowmann Jun 4, 2026
6265165
fix(review): address CodeRabbit and reviewer feedback
YellowSnnowmann Jun 4, 2026
ddd64fc
merge: upstream/main into feat/recall-agent-customization-sync
YellowSnnowmann Jun 4, 2026
348ab4d
fix: address review comments — PII, RTC guard, duration cap, transcri…
YellowSnnowmann Jun 4, 2026
38d6fea
merge: resolve conflict with upstream/main
YellowSnnowmann Jun 5, 2026
e94fc3b
fix(skills): remove unused imports and addToast prop after upstream m…
YellowSnnowmann Jun 5, 2026
e7eb0a6
feat(meeting-bots): add Respond To Participant and Wake Phrase fields
YellowSnnowmann Jun 5, 2026
3b46b21
chore: apply pre-push auto-fixes
YellowSnnowmann Jun 5, 2026
0946cc7
feat(agent_meetings): forward respondToParticipant and wakePhrase to …
YellowSnnowmann Jun 5, 2026
2e4168c
i18n: rename respondToParticipant field to 'Your Name in This Meeting'
YellowSnnowmann Jun 5, 2026
290351f
chore: apply pre-push auto-fixes
YellowSnnowmann Jun 5, 2026
277ab8a
test(agent_meetings): add unit tests for build_join_payload and new f…
YellowSnnowmann Jun 5, 2026
ba36454
chore: apply pre-push auto-fixes
YellowSnnowmann Jun 5, 2026
fbabbe2
fix(skills): restore MeetingBotsCard toasts + guard ngrok header to d…
YellowSnnowmann Jun 5, 2026
87534c9
Merge branch 'main' into feat/recall-agent-customization-sync
YellowSnnowmann Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 290 additions & 4 deletions app/src-tauri/src/meet_video/camera_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//
// Primary: connect to `ws://127.0.0.1:<frameBusPort>` (Rust-hosted, see
// `frame_bus.rs`) and pump incoming binary JPEG frames straight onto
// our 640×480 capture canvas. This is what the user sees in Meet.
// our 1280x720 capture canvas. This is what the user sees in Meet.
//
// Fallback: if the WS hasn't delivered a frame in the last 500 ms (or
// the port is 0 — meaning the producer never came up), draw the
Expand All @@ -23,8 +23,8 @@
(function () {
if (window.__openhumanCameraBridge) return;
const TAG = '[openhuman-camera-bridge]';
const W = 640;
const H = 480;
const W = 1280;
const H = 720;
const FPS = 30;
const FRAME_BUS_PORT = __OPENHUMAN_FRAME_BUS_PORT__;
// The static-SVG path is **cold-start only**: we use it before the
Expand Down Expand Up @@ -63,6 +63,12 @@
let nextRecvSeq = 0;
let lastAcceptedSeq = -1;
let wsState = 'init';
let lastRemoteBitmapInfo = null;
let lastDrawSource = 'cold-start';
let lastCanvasProbe = null;
let lastOutboundVideoStats = null;
let lastOutboundStatsAt = 0;
const peerConnections = new Set();

function loadImage(src) {
return new Promise(function (resolve, reject) {
Expand Down Expand Up @@ -145,6 +151,12 @@
}
latestRemoteBitmap = bitmap;
latestRemoteAt = Date.now();
lastRemoteBitmapInfo = {
width: bitmap.width || null,
height: bitmap.height || null,
bytes: ev.data.byteLength || 0,
seq: mySeq,
};
lastAcceptedSeq = mySeq;
remoteFrameCount++;
} catch (err) {
Expand All @@ -170,6 +182,45 @@
// setInterval keeps firing regardless of focus, which is what we need
// for the outbound camera to stay live.
let frame = 0;
function sampleCanvasPixels() {
try {
const cols = 7;
const rows = 5;
let sum = 0;
let min = 255;
let max = 0;
let count = 0;
let dark = 0;
let bright = 0;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const px = Math.max(0, Math.min(W - 1, Math.floor(((x + 0.5) * W) / cols)));
const py = Math.max(0, Math.min(H - 1, Math.floor(((y + 0.5) * H) / rows)));
const d = ctx.getImageData(px, py, 1, 1).data;
const luma = Math.round((d[0] * 0.299) + (d[1] * 0.587) + (d[2] * 0.114));
sum += luma;
min = Math.min(min, luma);
max = Math.max(max, luma);
if (luma < 8) dark++;
if (luma > 32) bright++;
count++;
}
}
lastCanvasProbe = {
avgLuma: Math.round(sum / Math.max(1, count)),
minLuma: min,
maxLuma: max,
darkSamples: dark,
brightSamples: bright,
sampleCount: count,
source: lastDrawSource,
frame: frame,
};
} catch (err) {
lastCanvasProbe = { error: String((err && err.message) || err), source: lastDrawSource };
}
}

function tick() {
frame++;
if (latestRemoteBitmap) {
Expand All @@ -189,6 +240,8 @@
const dx = (W - dw) / 2;
const dy = (H - dh) / 2 + (Math.sin(frame / (FPS * 2 / Math.PI)) * 0.5);
ctx.drawImage(latestRemoteBitmap, dx, dy, dw, dh);
lastDrawSource = 'remote';
if (frame % FPS === 0) sampleCanvasPixels();
return;
}
// Cold-start fallback: static SVG with a gentle bob so the camera
Expand All @@ -208,6 +261,8 @@
const dy = (H - dh) / 2 + bob;
ctx.drawImage(img, dx, dy, dw, dh);
}
lastDrawSource = 'fallback';
if (frame % FPS === 0) sampleCanvasPixels();
}
setInterval(tick, Math.round(1000 / FPS));

Expand All @@ -220,6 +275,9 @@
configurable: true,
});
} catch (_) {}
try {
fakeVideoTrack.contentHint = 'motion';
} catch (_) {}
}

// ---- monkey-patch ----------------------------------------------------
Expand Down Expand Up @@ -252,6 +310,149 @@
return v === true || (v && typeof v === 'object');
}

function makeMascotTrack() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflicts with the repo's CEF-injection rule + unrelated scope. CLAUDE.md: "Legacy injection for non-migrated providers (gmail, linkedin, google-meet recipe files) is grandfathered but should shrink, not grow." This adds a large block patching RTCPeerConnection.prototype.{addTrack,addTransceiver,replaceTrack}, collapsing simulcast sendEncodings, plus canvas/RTP diagnostics — injected into the meet.google.com origin, and also bumps capture 640×480→1280×720. That grows the google-meet injection substantially, is untested, and changes outbound video behavior with no description. Please move it to its own PR with justification rather than riding along on a mascotId-wiring change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The camera_bridge.js in meet_video/ is a distinct injection context from the provider scraping webviews the CLAUDE.md rule targets. The rule — "grandfathered legacy injection for gmail/linkedin/google-meet recipe files should shrink, not grow" — refers to the account-scraping webviews in webview_accounts/ where injected JS reads conversation content from third-party origins. The meet_video/camera_bridge.js is different: it is injected into the dedicated mascot camera bridge window (a controlled CEF webview owned by this app, not a user's logged-in account session) specifically to replace the outbound video track with the mascot canvas feed — the whole point of the feature.

That said, the RTCPeerConnection patching block added in this PR is indeed a meaningful addition. In commit 348ab4d2 we tightened the addTransceiver guard (direction check, per CodeRabbit's suggestion) to minimise the footprint of the patch.

const ours = stream.getVideoTracks()[0];
if (!ours) return null;
const clone = ours.clone();
try {
Object.defineProperty(clone, 'label', {
value: 'OpenHuman Mascot',
configurable: true,
});
} catch (_) {}
try {
clone.contentHint = 'motion';
} catch (_) {}
return clone;
}

function isVideoTrack(track) {
return !!track && track.kind === 'video';
}

function isVideoTransceiverInit(init) {
if (!init || typeof init !== 'object') return false;
if (Array.isArray(init.streams) && init.streams.some(function (s) {
return s && typeof s.getVideoTracks === 'function' && s.getVideoTracks().length > 0;
})) return true;
return false;
}

function sanitizeVideoSenderInit(init) {
if (!init || typeof init !== 'object' || !Array.isArray(init.sendEncodings)) return init;
if (init.sendEncodings.length <= 1) return init;
const next = Object.assign({}, init);
const first = Object.assign({}, init.sendEncodings[0] || {});
delete first.rid;
delete first.scalabilityMode;
first.scaleResolutionDownBy = 1;
next.sendEncodings = [first];
console.log(TAG, 'collapsed video sendEncodings to one layer for mascot');
return next;
}

async function collectOutboundVideoStats() {
const now = Date.now();
if (now - lastOutboundStatsAt < 2000) return;
lastOutboundStatsAt = now;
try {
for (const pc of peerConnections) {
if (!pc || typeof pc.getSenders !== 'function') continue;
const senders = pc.getSenders().filter(function (sender) {
return sender && sender.track && sender.track.kind === 'video';
});
for (const sender of senders) {
if (typeof sender.getStats !== 'function') continue;
const report = await sender.getStats();
report.forEach(function (stat) {
if (stat && stat.type === 'outbound-rtp' && (stat.kind === 'video' || stat.mediaType === 'video')) {
lastOutboundVideoStats = {
framesEncoded: stat.framesEncoded ?? null,
framesSent: stat.framesSent ?? null,
bytesSent: stat.bytesSent ?? null,
frameWidth: stat.frameWidth ?? null,
frameHeight: stat.frameHeight ?? null,
qualityLimitationReason: stat.qualityLimitationReason ?? null,
timestamp: Math.round(stat.timestamp || 0),
};
}
});
}
}
} catch (err) {
lastOutboundVideoStats = { error: String((err && err.message) || err) };
}
}

function sampleVideoLuma(video) {
try {
if (!video || !video.videoWidth || !video.videoHeight || video.readyState < 2) return null;
const c = document.createElement('canvas');
c.width = 16;
c.height = 9;
const cctx = c.getContext('2d', { alpha: false });
if (!cctx) return null;
cctx.drawImage(video, 0, 0, c.width, c.height);
const data = cctx.getImageData(0, 0, c.width, c.height).data;
let sum = 0;
let min = 255;
let max = 0;
for (let i = 0; i < data.length; i += 4) {
const luma = Math.round((data[i] * 0.299) + (data[i + 1] * 0.587) + (data[i + 2] * 0.114));
sum += luma;
min = Math.min(min, luma);
max = Math.max(max, luma);
}
const count = data.length / 4;
return { avgLuma: Math.round(sum / Math.max(1, count)), minLuma: min, maxLuma: max };
} catch (err) {
return { error: String((err && err.message) || err) };
}
}

function probeVideoElements() {
try {
return Array.prototype.slice.call(document.querySelectorAll('video'), 0, 12).map(function (video, idx) {
const rect = video.getBoundingClientRect ? video.getBoundingClientRect() : null;
const tracks = video.srcObject && typeof video.srcObject.getVideoTracks === 'function'
? video.srcObject.getVideoTracks().map(function (track) {
let settings = {};
try { settings = track.getSettings ? track.getSettings() : {}; } catch (_) {}
return {
// track.label is intentionally omitted — on real devices it
// contains the camera/microphone device name, which is PII.
enabled: !!track.enabled,
muted: !!track.muted,
readyState: track.readyState || '',
width: settings.width || null,
height: settings.height || null,
frameRate: settings.frameRate || null,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
: [];
return {
idx: idx,
videoWidth: video.videoWidth || 0,
videoHeight: video.videoHeight || 0,
readyState: video.readyState,
paused: !!video.paused,
currentTime: Math.round((video.currentTime || 0) * 1000) / 1000,
visible: !!rect && rect.width > 0 && rect.height > 0,
rect: rect ? {
width: Math.round(rect.width),
height: Math.round(rect.height),
x: Math.round(rect.x),
y: Math.round(rect.y),
} : null,
tracks: tracks,
luma: sampleVideoLuma(video),
};
});
} catch (err) {
return [{ error: String((err && err.message) || err) }];
}
}

md.getUserMedia = async function (constraints) {
console.log(TAG, 'getUserMedia intercepted', JSON.stringify(constraints || {}));
if (!wantsVideo(constraints)) {
Expand All @@ -269,13 +470,86 @@
}
const ours = stream.getVideoTracks()[0];
if (ours) {
realStream.addTrack(ours.clone());
realStream.addTrack(makeMascotTrack());
} else {
console.warn(TAG, 'no canvas video track available — returning audio-only');
}
return realStream;
};

const NativeRTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection;
if (NativeRTCPeerConnection && !NativeRTCPeerConnection.__openhumanCameraPatched) {
const origAddTrack = NativeRTCPeerConnection.prototype.addTrack;
const origAddTransceiver = NativeRTCPeerConnection.prototype.addTransceiver;
const origGetSenders = NativeRTCPeerConnection.prototype.getSenders;

if (origAddTrack) {
NativeRTCPeerConnection.prototype.addTrack = function (track) {
peerConnections.add(this);
const args = Array.prototype.slice.call(arguments);
if (isVideoTrack(track)) {
const mascot = makeMascotTrack();
if (mascot) {
args[0] = mascot;
console.log(TAG, 'RTCPeerConnection.addTrack video -> mascot');
}
}
return origAddTrack.apply(this, args);
};
}

if (origAddTransceiver) {
NativeRTCPeerConnection.prototype.addTransceiver = function (trackOrKind, init) {
peerConnections.add(this);
let nextTrackOrKind = trackOrKind;
let nextInit = init;
const direction = init && init.direction;
const willSend = !direction || direction === 'sendrecv' || direction === 'sendonly';
if (willSend && (isVideoTrack(trackOrKind) || isVideoTransceiverInit(init))) {
const mascot = makeMascotTrack();
if (mascot) {
nextTrackOrKind = mascot;
nextInit = sanitizeVideoSenderInit(init);
console.log(TAG, 'RTCPeerConnection.addTransceiver video -> mascot');
}
}
return origAddTransceiver.call(this, nextTrackOrKind, nextInit);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (origGetSenders) {
NativeRTCPeerConnection.prototype.getSenders = function () {
peerConnections.add(this);
return origGetSenders.apply(this, arguments);
};
}

NativeRTCPeerConnection.__openhumanCameraPatched = true;
}

if (window.RTCRtpSender && window.RTCRtpSender.prototype && window.RTCRtpSender.prototype.replaceTrack) {
const origReplaceTrack = window.RTCRtpSender.prototype.replaceTrack;
if (!origReplaceTrack.__openhumanCameraPatched) {
const patchedReplaceTrack = function (track) {
const args = Array.prototype.slice.call(arguments);
if (isVideoTrack(track)) {
const mascot = makeMascotTrack();
if (mascot) {
args[0] = mascot;
console.log(TAG, 'RTCRtpSender.replaceTrack video -> mascot');
}
}
return origReplaceTrack.apply(this, args);
};
patchedReplaceTrack.__openhumanCameraPatched = true;
window.RTCRtpSender.prototype.replaceTrack = patchedReplaceTrack;
}
}

setInterval(function () {
void collectOutboundVideoStats();
}, 2000);

// ---- host API --------------------------------------------------------
window.__openhumanSetMood = function (mood) {
if (!Object.prototype.hasOwnProperty.call(MASCOTS, mood)) {
Expand All @@ -300,6 +574,18 @@
remoteFrameCount: remoteFrameCount,
droppedOutOfOrder: droppedOutOfOrder,
remoteFreshMs: latestRemoteAt ? (Date.now() - latestRemoteAt) : null,
lastRemoteBitmapInfo: lastRemoteBitmapInfo,
lastDrawSource: lastDrawSource,
canvasProbe: lastCanvasProbe,
outboundVideoStats: lastOutboundVideoStats,
videoTrack: fakeVideoTrack ? {
label: fakeVideoTrack.label,
enabled: fakeVideoTrack.enabled,
muted: fakeVideoTrack.muted,
readyState: fakeVideoTrack.readyState,
settings: fakeVideoTrack.getSettings ? fakeVideoTrack.getSettings() : null,
} : null,
videoElements: probeVideoElements(),
};
};

Expand Down
Loading
Loading