8.1 KiB
Video Mode — Deep Dive
Video mode lets the assistant see the user (webcam) and their screen (screen share), in three presentations: frames attached to normal chat, a hands-free spoken call, and a full-screen Meet-style call. This doc covers the product flow, the technical pipeline, and the LLM prompt surface with exact pointers.
Product flow
The composer's video button (chat-input-with-mentions.tsx) toggles video
mode; a chevron dropdown picks one of three modes (VideoChatMode):
| Mode | What it does |
|---|---|
chat — "Video + chat" |
Camera on. Webcam (and screen-share) frames ride along with every typed or dictated message. Small PiP preview floats above the composer. |
call — "Video call (hands-free)" |
Everything in chat, plus: continuous listening (each utterance auto-submits as a voice message) and forced full read-aloud TTS. No typing needed; composer still works. |
meeting — "Video call (full screen)" |
Same pipeline as call, presented as a full-screen Meet-style layout: user tile + animated mascot tile, captions, control bar. |
On top of any mode:
- Screen share (
MonitorUpbuttons on the PiP overlay and the meeting control bar): captures the primary screen; frames go to the model as a separately labeled group. In the meeting view the screen becomes the big tile with user + mascot in a side rail. - Camera off (Meet-style mute): video mode and screen share keep running, no webcam frames are captured; tiles show a silhouette avatar.
- Mascot dismissal (meeting view): swaps the animated mascot for a Meet-style letter avatar ("R").
- Popout: while screen sharing, if the app window loses focus (the user
switched to the app they're sharing), a small always-on-top frameless
window pops out with the user + mascot mini-tiles; refocusing dismisses it.
Its expand button focuses the main window (
video:focusMain).
call/meeting options are disabled unless both voice input (Deepgram) and
voice output (TTS) are configured. Entering a call saves the user's TTS
settings and forces full read-aloud; leaving restores them.
Frame pipeline
apps/renderer/src/hooks/useVideoMode.ts runs one capture pipe per source
(stream → offscreen <video> → canvas JPEG → ring buffer):
- Cadence: 1 fps (
CAPTURE_INTERVAL_MS, line 20); ring buffer ~2 min. - Webcam: 512px wide, JPEG q0.65, max 12 frames/message (lines 21, 31).
- Screen: 1280px wide (text legibility), JPEG q0.7, max 4 frames/message (lines 24, 32).
collectFrames()drains frames buffered since the last send, evenly sampled down to the caps, always keeping the newest; grabs one final frame at the moment of send. Falls back to the single latest frame for rapid-fire messages.
App.tsx handlePromptSubmit (~line 2767) attaches the drained frames to
the outgoing message as UserImageParts and sets
composition.videoMode: true. Frames also become isVideoFrame display
attachments (filmstrip in the transcript — chat-message-attachments.tsx;
history hydration in lib/run-to-conversation.ts).
Message schema & model encoding
packages/shared/src/message.ts:51—UserImagePart: inline base64 (data,mediaType),source: 'camera' | 'screen',capturedAt. Unlike file attachments (path references read via theLLMParsetool), image parts go to the model as real multimodal image parts.packages/core/src/agents/runtime.tsconvertFromMessages(~line 1013): emits a context line (frame counts + time span), then labeled groups — a"Webcam frames (oldest to newest):"text part before camera images and a"Screen-share frames (oldest to newest):"text part before screen images — so the model never confuses the user with their screen.- Frames stay inline in history (no pruning) deliberately: pruning would bust provider prefix caching every turn and cost more than it saves.
- The auto-permission classifier stringifies + truncates content to ~3KB per message, so inline base64 can't blow up its prompt.
Hands-free voice loop
apps/renderer/src/hooks/useVoiceMode.ts:
startContinuous(onUtterance)(line 404): push-to-talk params but withendpointing=1800(line 25) so thinking pauses don't cut the user off, plusutterance_end_ms=2000(line 38) as a second end-of-speech signal. Gotcha: Deepgram'sspeech_finalusually arrives on a result with an EMPTY transcript — empty finals must reach the endpoint check or utterances never complete (see the NOTE inws.onmessage).setPaused(true)(line 414) while the assistant thinks/speaks: drops mic audio (so TTS is never transcribed back), discards half-heard buffer, sends Deepgram KeepAlives every 5s.App.tsxdrives this fromactiveIsProcessing || tts.state !== 'idle'.- Mid-call socket drops reconnect after 1s; the offline audio backlog is capped (~30s).
Mode transitions live in App.tsx handleVideoModeChange (~line 1161):
call ↔ meeting switches are presentation-only (mic/TTS untouched);
entering/leaving hands-free saves/restores TTS settings. Push-to-talk is
disabled while a call owns the mic.
Popout window
- Renderer asks
video:setPopout {show}(main handler:apps/main/src/ipc.ts:1742); main creates a frameless,alwaysOnTop('floating'), all-workspaces BrowserWindow at the top-right of the primary display, loading the renderer bundle with#video-popout(apps/renderer/src/main.tsxbranches on the hash →components/video-popout.tsx). - Call state streams over the
video:popout-statepush channel; main caches the last payload and replays it on popout load. Shown withshowInactive()so it never steals focus (that would re-hide it). - The popout captures its own camera preview (MediaStreams can't cross windows) and synthesizes the mascot mouth level (no audio in that window).
video:focusMainmatches only real app windows by URL —getAllWindows()also contains hidden utility windows (PDF export) that must not be shown.
Permissions
- Camera:
voice:ensureCameraAccesssettles the macOS TCC prompt beforegetUserMedia(same pattern as the mic).NSCameraUsageDescriptionis inforge.config.cjsextendInfo. - Screen:
getDisplayMediais auto-approved with the primary screen bysetDisplayMediaRequestHandlerinmain.ts(no picker);meeting:checkScreenPermissionregisters the app in macOS Screen Recording settings on first use.
LLM prompts catalog
| Prompt | Where |
|---|---|
# Video Mode (Live Camera) system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) |
packages/core/src/agents/runtime.ts:386 (composeSystemInstructions, gated on videoMode) |
Per-message frame context line [Video mode: N live webcam frames … and M frames of the user's shared screen …] + group labels |
packages/core/src/agents/runtime.ts:~1013 (convertFromMessages) |
videoMode composition override (session-sticky; flips bust prefix cache) |
packages/core/src/turns/bridges/real-agent-resolver.ts:57,125; set from App.tsx sendConfig |
Voice input/output prompt sections (# Voice Input, # Voice Output) are
reused untouched — calls set voiceInput per utterance and force
voiceOutput: 'full'.
Cost notes
Webcam frames ≈ 250–350 tokens each (≤12/message ≈ 3–4k); screen frames ≈ 1.5–2k tokens each (≤4/message ≈ 6–8k). History keeps frames inline, so long sessions grow but stay prefix-cached. First lever if cost bites: drop to one screen frame per message unless the screen changed.
Known limitations
- Turn-taking is strict — no barge-in (would need echo cancellation against TTS output).
- Frame sampling, not video: motion between frames is invisible (the prompt tells the model not to claim otherwise).
- Vocal-delivery feedback is limited: Deepgram reduces speech to text, so "energy" coaching leans on visual cues.
- Screen share always captures the primary display (no window/display picker yet).
- The meeting view covers the chat; there's no in-call transcript drawer.