Repository navigation
Recording Pipeline
This page follows a recording from the moment you pick a source to the saved file: capture, compositing, autosave, stopping, playback, the list of latest videos, and the optional seekable fix and compression on save. Read it before changing anything in src/platform/, src/lib/capture.ts, compositor.ts, session.ts, ticker.ts, db.ts, formats.ts, optimize.ts, compress.ts or thumbnail.ts. The Architecture page covers how the popup, worker and recorder window reach each other, and the Upload Pipeline page covers saving to a connected service. The Recording page describes the same features from the user's side.
All recording runs in the recorder page (recorder.tsx), which drives one RecordingSession from session.ts through these stages:
-
Open.
RecordingSession.open(settings, callbacks)asks for the microphone, then the camera, then the screen, and builds the compositor andMediaRecorder. Nothing is recorded yet. -
Countdown. The recorder page counts down 3 seconds, on the page and on the toolbar badge. Stopping during the countdown calls
session.cancel(), which releases the streams and keeps no recording. -
Record.
session.record()makes room in the database, creates a recording row and startsMediaRecorderwith a 5 s timeslice. Each chunk goes to IndexedDB as it arrives. -
Pause and resume.
togglePause()pauses and resumesMediaRecorder.elapsedMsleaves out the time spent paused. -
Stop.
session.stop(optimize)stopsMediaRecorder, waits for every chunk to be written, joins the chunks into one blob and, for WebM, writes a duration header so players can seek it. - Save. The finished view offers playback, Save, Discard and New recording. Save goes through the save dialog, which can compress the video first and can send it to a connected service instead of the downloads folder.
The recorder page tracks these stages as a Phase union: idle, starting, countdown, recording, stopping and finished.
The screen picker differs by browser, so it lives in src/platform/ and the session calls it as platform.screen.captureScreen. capture.ts holds what's the same everywhere: the camera, the microphone, audio mixing and screenCaptureSize. Each failure is a CaptureError with a kind (cancelled, camera, microphone, system-audio or screen), which the recorder page turns into a toast.
RecordingSession.open asks for the microphone and camera before the screen, so their permission prompts appear before the browser's screen picker. The microphone and camera are optional extras when a screen is also being captured: if either fails, the session reports a warning through onWarning and carries on without it. The camera is required for a camera-only recording, so its failure there aborts the session.
captureScreen in platform/chrome.ts calls browser.desktopCapture.chooseDesktopMedia with the source types screen, window and tab, plus audio when system audio is wanted. Chrome's picker returns a stream id, which getUserMedia turns into a stream using Chrome's legacy mandatory constraints (chromeMediaSource: 'desktop'). Those constraints carry maxWidth, maxHeight and maxFrameRate, and Chrome scales the source down to fit while keeping its shape.
When you pick a source that has no audio to share, for example a window, hasAudio comes back false and the session warns that system audio wasn't found.
Firefox has no desktopCapture. captureScreen in platform/firefox.ts calls the standard navigator.mediaDevices.getDisplayMedia, with the size as max width and height and the frame rate as ideal and max. Its picker offers screens and windows only, and it never asks for audio, so hasAudio is always false. The recorder hides the system audio choices in Firefox, so the session doesn't ask for it there. Closing the picker raises NotAllowedError or AbortError, which becomes a cancelled capture error.
Firefox only opens the picker from a click in the page. A record button in the toolbar menu therefore opens the recorder with the source picked and waits for Start recording, as the Architecture page describes. The countdown runs after you pick what to share, in both browsers.
The Resolution setting supplies the cap for both pickers. "Native" is stored as 0 × 0, and screenCaptureSize turns it into the screen size times devicePixelRatio, capped at 3840 × 2160.
If you end the share from the browser's own "Stop sharing" control, the screen track fires ended, and the session calls onEndedExternally, which stops the recording as if you had pressed Stop.
captureCamera and captureMicrophone call getUserMedia with the device ids from Settings as ideal constraints, so a missing device falls back to the default one. The camera also asks for the resolution and frame rate from Settings.
AudioMixer sends every audio source through one AudioContext into a MediaStreamAudioDestinationNode, so the recording has a single mixed audio track. With "System + mic", the screen's audio and the microphone both feed the mixer. With audio set to None, the session creates no mixer and the recording has no audio track.
The recording is always of a canvas, never of the raw screen stream. compositor.ts plays each stream in a detached, muted <video> and draws the frames onto one canvas with alpha: false. canvas.captureStream(fps) provides the video track for MediaRecorder and the live preview.
The canvas takes the size of the first full-frame source: the screen video's native size, or the camera's when recording the camera alone. Each frame, draw() fills the canvas black, then draws its layers in order:
- The full-frame layer is the screen, or the camera alone. It's scaled to fit inside the canvas and centred, with black bars where the shapes differ. A camera-only recording is mirrored when the mirror setting is on.
-
The overlay layer is the camera in a corner over the screen.
overlayBoxsizes and places it, and the frame is clipped to the overlay's shape before the camera is drawn.
overlayBox measures against the widest 16:9 area that fits the canvas (min(width, height × 16/9)), so an ultrawide or portrait capture doesn't get an overlay that swamps it. The overlay width is a share of that reference: 0.25 for small, 1/3 for medium and 0.45 for large. At 1080p that's 480, 640 and 864 px. The gap to the frame edge is 1.25 % of the reference, at least 8 px.
The default shape is a 16:9 rectangle. Circle and squircle use a square box as tall as the 16:9 overlay would be. The squircle outline comes from squircle.ts, which the Settings preview also uses so the two match. Inside the clip, the camera image is scaled to cover the box, then enlarged by the zoom setting (1 to 2) around its centre, and flipped when mirrored.
ticker.ts calls draw() about fps times a second, even when the recorder window is hidden or minimised. Browsers stop requestAnimationFrame and throttles timers in background windows, which would freeze the recording. Audio keeps running in the background, so the ticker schedules a silent oscillator that ends one frame later, and its onended handler draws a frame and schedules the next one.
formats.ts picks the MediaRecorder MIME type for the Format setting. pickMimeType takes the first candidate that MediaRecorder.isTypeSupported accepts:
| Format | Candidates, in order |
|---|---|
| MP4 |
video/mp4;codecs=avc1.640028,mp4a.40.2, video/mp4;codecs=avc1,mp4a.40.2, video/mp4
|
| WebM |
video/webm;codecs=h264,opus, video/webm;codecs=vp9,opus, video/webm;codecs=vp8,opus, video/webm
|
MP4 is the default. When the browser can't record any MP4 candidate, pickMimeType falls back to WebM, the format select shows "MP4 (not supported)" and disables it, and getSettings() reports the format as WebM. The recording row stores the MIME type MediaRecorder reports, and the file extension follows it.
The same file builds file names: recordingFileName gives screen-recording-<yyyy>-<mm>-<dd>-<hhmmss>.<ext> in local time, and cleanFileName replaces characters that file systems reject before a typed-in name is used.
db.ts stores each recording as a row in recordings and its data as many rows in chunks, linked by the recording index. MediaRecorder hands over a chunk every 5 s (CHUNK_MS in session.ts), and the session writes each one straight away. The session chains the writes on one promise, so they land in order and stop() can wait for the last one.
A crash or a closed window therefore loses at most the last 5 s. The row's durationMs is set only when the recording stops normally. A recording without it shows an "Interrupted" badge and "Recovered" in place of its length in Latest videos (videos.tsx), where you can still play, save or delete it. If the recorder page unloads mid-recording, its cleanup effect calls session.stop(false), which finishes the row without the seekable fix.
getRecordingBlob rebuilds the video by joining the chunks in key order, which is recording order because the keys auto-increment. Before each recording starts, pruneRecordings() deletes the oldest recordings until 4 remain, so the new one makes 5 (MAX_RECORDINGS). It skips a recording with an upload still running, so the list can briefly hold more than 5. Deleting a recording also deletes its chunks and its upload rows.
The finished view, Latest videos and the Player page all play a video through RecordedVideo in recorded-video.tsx, which draws its own controls over a <video> instead of the browser's:
- Play and pause, 5 s back and forward, a seek bar that shows what's buffered, volume, playback speed (1×, 1.25×, 1.5×, 2× and 0.5×, in that order), picture in picture where the browser supports it, and full screen.
- Space or K plays and pauses, the left and right arrows skip 5 s, M mutes and F goes full screen. The keys are ignored while focus is in a form field or button, or while a dialog is open over the player.
- While playing, the controls hide after 2.5 s without pointer movement and come back when the pointer moves.
A WebM straight from MediaRecorder has no duration in its header. The recorder and Latest videos pass the row's durationMs, which the player shows until it knows better. When the browser reports an infinite duration, the player seeks far past the end, which makes it read the whole file and report the real length, then goes back to the start.
The list in videos.tsx shows each recording with a thumbnail, its length over the thumbnail when known, and its title.
Thumbnails. makeThumbnail in thumbnail.ts loads the recording in a detached, muted <video>, seeks 1 s in (or a tenth of the way, for clips under 10 s) and draws that frame into a 320 px wide canvas as a JPEG at quality 0.8. A WebM without a duration can't seek, so it gets its first frame. It gives up after 8 s. The list makes missing thumbnails one at a time, newest first, the first time it shows a recording, and stores each on the row's thumbnail field so it's only made once.
Names. A recording's title is the name it was saved as, or its date when it has none. When you save under a name other than the default, the save dialog stores it, without the extension, on the row's name field. The next save starts from that name.
MediaRecorder writes WebM without a duration, so players can't show its length or seek it. MP4 from MediaRecorder doesn't have this problem. optimize.ts fixes WebM files with fix-webm-duration, which writes the duration into the file header.
With the "Optimize video (make seekable)" setting on (the default), stop() runs makeSeekable on a WebM recording, replaces its chunks with the single fixed blob through replaceRecordingBlob, and sets seekable: true on the row. For a recording that skipped the fix, Latest videos shows a "Make seekable" button, and the Player page offers the same for a WebM file from disk. When no duration is known, as after a crash, probeDuration loads the file in a <video> and seeks past the end, which makes the browser scan to the real end and report the duration.
The Save button calls useSaveVideo() in save-video.tsx. With "Show save options" off, it downloads the original straight away. Otherwise it opens a dialog for the file name, the destination and the size:
-
Save to is This computer or a connected service or upload server. The dialog remembers the choice in the
saveDestinationsetting, and falls back to This computer when the remembered service isn't connected any more. With no service connected, it links to Settings instead. -
Size is the original or one of the compression presets. The dialog remembers the last size picked in the
saveSizesetting.
A compressed copy is made first, then downloaded or uploaded. The Upload Pipeline page covers what happens after the dialog hands a file to a service.
compress.ts re-encodes a video with Mediabunny over WebCodecs, which uses the browser's own encoders. Firefox has had WebCodecs since version 130, and the extension needs 140. Each preset caps the output height, and the width at the matching 16:9 width, and sets a bitrate:
| Preset | Max height | Video bitrate | Audio bitrate |
|---|---|---|---|
| High quality | 1080 | 4 Mbit/s | 128 kbit/s |
| Balanced | 720 | 2 Mbit/s | 96 kbit/s |
| Small | 720 | 1 Mbit/s | 64 kbit/s |
| Smallest | 480 | 500 kbit/s | 48 kbit/s |
When the dialog opens, analyzeVideo reads the duration, size and tracks, and planPreset works out each preset's output:
- The video is scaled down to fit the cap and never scaled up, with both sides rounded to even numbers.
- The video bitrate drops in proportion when the output has fewer pixels than the preset's full size.
- The estimated size is
(video + audio bitrate) × duration ÷ 8, plus 2 % for the container. - A preset whose estimate isn't under 90 % of the current file size is greyed out as "No smaller".
planPresets also checks that the browser can encode each preset, by asking Mediabunny for the first video and audio codec that works at that size. Presets it can't encode aren't shown. If none can be encoded, the dialog says the browser can't encode this video on this computer and offers only the original.
A video over 25 MiB (LARGE_FILE_BYTES, about the attachment limit of email and most chat apps) shows a notice with "Save a smaller copy". That button opens the dialog with suggestedPreset picked: the best-quality encodable preset whose estimate is under 25 MiB, or else the smallest one that helps.
compressVideo keeps the container: MP4 stays MP4 and WebM stays WebM. It takes the first codec the browser can encode at the target size, from AVC, HEVC, VP9 and AV1 for MP4, or VP9, VP8 and AV1 for WebM. Audio is AAC or Opus in MP4 and Opus or Vorbis in WebM, and the audio track is dropped if the browser can encode neither. MP4 output is written with its index at the front (fastStart: 'in-memory'). The dialog shows progress, and its Stop button cancels the conversion.
downloadBlob in download.ts saves through the browser's normal download flow: it clicks a hidden <a download> pointing at an object URL. It revokes the URL after 60 s, because revoking it straight away can cancel the download in some browser versions. After a save, the recording row gets saved: true, which Latest videos shows as a "Saved" badge. A finished upload counts as a save too.
Using Screen Tools
- Installation and Updating
- Firefox
- Recording
- Screenshots
- Uploading
- Settings
- Troubleshooting and FAQ
- Privacy
How it works
Contributing