Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 46e773698d4102c284d30369
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/capture.ts
// The screen, camera and microphone: opening them, mixing their sound, drawing the camera bubble onto
// the screen, and recording the result with the browser's own MediaRecorder. Everything stays in this tab.
import { lockName, putChunk } from "./store";
export type Mode = "screen" | "camera" | "audio";
export type Corner = "tl" | "tr" | "bl" | "br";
export type Format = "mp4" | "webm";
const devices = typeof navigator === "undefined" ? undefined : navigator.mediaDevices;
export const CAN_RECORD = typeof MediaRecorder !== "undefined" && !!devices?.getUserMedia;
/** Phone browsers cannot capture the screen; they get the camera and audio modes. */
export const CAN_SCREEN = CAN_RECORD && !!devices?.getDisplayMedia;
const TYPES = {
video: { mp4: ["video/mp4;codecs=avc1,mp4a.40.2", "video/mp4"], webm: ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"] },
audio: { mp4: ["audio/mp4;codecs=mp4a.40.2", "audio/mp4"], webm: ["audio/webm;codecs=opus", "audio/webm"] },
};
const supported = (t: string) => CAN_RECORD && MediaRecorder.isTypeSupported(t);
/** File types this browser can record video as, MP4 first because it plays everywhere. */
export const FORMATS = (["mp4", "webm"] as Format[]).filter((f) => TYPES.video[f].some(supported));
function mimeType(kind: "video" | "audio", format: Format): string | undefined {
for (const f of format === "mp4" ? (["mp4", "webm"] as const) : (["webm", "mp4"] as const)) {
const t = TYPES[kind][f].find(supported);
if (t) return t;
}
return undefined;
}
/** A reason a device could not start, worded for the person. */
export class CaptureProblem extends Error {}
function problem(err: unknown, what: "screen" | "camera" | "microphone"): CaptureProblem {
const name = (err as { name?: string })?.name;
if (what === "screen" && name === "NotAllowedError") {
return new CaptureProblem("No screen was shared. If you picked one, check that your computer lets the browser record the screen (on a Mac: System Settings, Privacy and Security, Screen Recording).");
}
if (name === "NotAllowedError" || name === "SecurityError") return new CaptureProblem(`The ${what} is blocked. Allow it from the icon in the address bar, then try again.`);
if (name === "NotFoundError" || name === "OverconstrainedError") return new CaptureProblem(`No ${what} was found on this device.`);
if (name === "NotReadableError" || name === "AbortError") return new CaptureProblem(`The ${what} could not start. Another app may be using it.`);
return new CaptureProblem(`The ${what} could not start.`);
}
export function camera(deviceId: string): Promise<MediaStream> {
return devices!
.getUserMedia({ video: { deviceId: deviceId || undefined, facingMode: deviceId ? undefined : "user", width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } } })
.catch((err) => { throw problem(err, "camera"); });
}
function microphone(deviceId: string): Promise<MediaStream> {
return devices!
.getUserMedia({ audio: { deviceId: deviceId || undefined, echoCancellation: true, noiseSuppression: true, autoGainControl: true } })
.catch((err) => { throw problem(err, "microphone"); });
}
function screen(sound: boolean): Promise<MediaStream> {
return devices!
.getDisplayMedia({
video: { frameRate: { ideal: 30 }, width: { max: 1920 }, height: { max: 1080 } },
audio: sound,
// Chrome and Edge: leave this tab out of the picker, allow switching what is shared mid-recording, offer system sound.
selfBrowserSurface: "exclude",
surfaceSwitching: "include",
systemAudio: sound ? "include" : "exclude",
} as DisplayMediaStreamOptions)
.catch((err) => { throw problem(err, "screen"); });
}
export type Plan = { mode: Mode; camera: boolean; mic: boolean; screenAudio: boolean; micId: string; camId: string; corner: () => Corner };
export type Sources = {
/** What gets recorded. */
stream: MediaStream;
/** The camera, shown on the page while recording in camera mode. */
camera: MediaStream | null;
/** How loud the microphone is right now, 0 to 1; null when there is nothing to show. */
level: () => number | null;
/** Screen sound was asked for, but the browser shared none. */
noScreenSound: boolean;
/** Runs when the person stops sharing from the browser's own bar. */
onEnded: (fn: () => void) => void;
release: () => void;
};
/** Opens what the plan needs. Everything it opens is closed by `release`. */
export async function open(plan: Plan): Promise<Sources> {
const held: MediaStream[] = [];
const cleanups: (() => void)[] = [];
const release = () => {
for (const f of cleanups) f();
for (const s of held) for (const t of s.getTracks()) t.stop();
};
try {
// The screen picker comes first: browsers only open it straight after a click.
const display = plan.mode === "screen" ? await screen(plan.screenAudio) : null;
if (display) held.push(display);
const mic = plan.mic || plan.mode === "audio" ? await microphone(plan.micId) : null;
if (mic) held.push(mic);
const cam = plan.mode === "camera" || (plan.mode === "screen" && plan.camera) ? await camera(plan.camId) : null;
if (cam) held.push(cam);
let video: MediaStreamTrack | undefined;
if (display && cam) {
const drawn = await bubble(display, cam, plan.corner);
cleanups.push(drawn.stop);
video = drawn.track;
} else {
video = (display || cam)?.getVideoTracks()[0];
}
// Web Audio only measures the microphone and mixes two sources; a single source is recorded as it is,
// so a sound system that never starts (Safari outside a click, a computer with no speakers) cannot silence a recording.
// It is made after the devices open: made earlier, Firefox can stall the microphone when it has no sound output.
const audio = new AudioContext();
void audio.resume();
cleanups.push(() => void audio.close());
const stream = new MediaStream([video, mixed(audio, [display, mic])].filter((t): t is MediaStreamTrack => !!t));
held.push(stream);
return {
stream,
camera: plan.mode === "camera" ? cam : null,
level: mic ? meter(audio, mic) : () => null,
noScreenSound: !!display && plan.screenAudio && !display.getAudioTracks().length,
onEnded: (fn) => display?.getVideoTracks()[0].addEventListener("ended", fn),
release,
};
} catch (err) {
release();
throw err;
}
}
/** One sound track for the recording: a single source as it is, or the microphone and the screen's sound
* (only Chrome and Edge share both) mixed. */
function mixed(audio: AudioContext, sources: (MediaStream | null)[]): MediaStreamTrack | undefined {
const tracks = sources.flatMap((s) => s?.getAudioTracks() ?? []);
if (tracks.length < 2) return tracks[0];
const out = audio.createMediaStreamDestination();
for (const t of tracks) audio.createMediaStreamSource(new MediaStream([t])).connect(out);
return out.stream.getAudioTracks()[0];
}
/** How loud the microphone is right now, 0 to 1, for the level bar; null while Web Audio is not running. */
function meter(audio: AudioContext, mic: MediaStream): () => number | null {
const node = audio.createAnalyser();
node.fftSize = 1024;
audio.createMediaStreamSource(mic).connect(node);
const samples = new Float32Array(node.fftSize);
return () => {
if (audio.state !== "running") return null;
node.getFloatTimeDomainData(samples);
let sum = 0;
for (const v of samples) sum += v * v;
return Math.min(1, Math.sqrt(sum / samples.length) * 5);
};
}
function playing(stream: MediaStream): Promise<HTMLVideoElement> {
const v = document.createElement("video");
v.muted = true;
v.playsInline = true;
v.srcObject = stream;
return new Promise((resolve, reject) => {
v.onloadedmetadata = () => v.play().then(() => resolve(v), reject);
});
}
/** Draws the shared screen with the camera in a circle in one corner, 30 times a second. */
async function bubble(display: MediaStream, cam: MediaStream, corner: () => Corner) {
const sv = await playing(display);
const cv = await playing(cam);
const fit = Math.min(1, 1920 / sv.videoWidth, 1080 / sv.videoHeight);
const canvas = document.createElement("canvas");
canvas.width = Math.round((sv.videoWidth * fit) / 2) * 2;
canvas.height = Math.round((sv.videoHeight * fit) / 2) * 2;
const g = canvas.getContext("2d", { alpha: false })!;
const draw = () => {
const W = canvas.width, H = canvas.height;
if (!sv.videoWidth || !cv.videoWidth) return;
// What is shared can change size mid-recording (a resized window, another tab): fit it in.
const s = Math.min(W / sv.videoWidth, H / sv.videoHeight);
const w = sv.videoWidth * s, h = sv.videoHeight * s;
g.fillStyle = "#000";
g.fillRect(0, 0, W, H);
g.drawImage(sv, (W - w) / 2, (H - h) / 2, w, h);
const d = Math.round(Math.min(W, H) * 0.28);
const m = Math.round(Math.min(W, H) * 0.035);
const c = corner();
const x = c[1] === "l" ? m : W - m - d;
const y = c[0] === "t" ? m : H - m - d;
const side = Math.min(cv.videoWidth, cv.videoHeight);
g.save();
g.beginPath();
g.arc(x + d / 2, y + d / 2, d / 2, 0, Math.PI * 2);
g.clip();
g.drawImage(cv, (cv.videoWidth - side) / 2, (cv.videoHeight - side) / 2, side, side, x, y, d, d);
g.restore();
g.lineWidth = Math.max(2, d * 0.018);
g.strokeStyle = "#fff";
g.beginPath();
g.arc(x + d / 2, y + d / 2, d / 2, 0, Math.PI * 2);
g.stroke();
};
const tick = new Worker(new URL("./tick.ts", import.meta.url), { type: "module" });
tick.onmessage = draw;
tick.postMessage(1000 / 30);
draw();
return {
track: canvas.captureStream(30).getVideoTracks()[0],
stop: () => {
tick.terminate();
sv.srcObject = null;
cv.srcObject = null;
},
};
}
export type Take = {
/** The media type of the file being made. */
type: string;
/** Seconds recorded so far, leaving out pauses. */
elapsed: () => number;
pause: () => void;
resume: () => void;
/** Stops recording; `done` resolves with the file. */
stop: () => void;
/** Resolves when recording stops for any reason: Stop, sharing ended, or the browser gave up. */
done: Promise<Blob>;
};
export function record(stream: MediaStream, mode: Mode, format: Format, id: string): Take {
const type = mimeType(mode === "audio" ? "audio" : "video", format);
const rec = new MediaRecorder(stream, {
mimeType: type,
videoBitsPerSecond: mode === "screen" ? 3_000_000 : 2_500_000,
audioBitsPerSecond: 128_000,
// A key frame every 2 seconds. Chrome hands over MP4 data only at key frames, so this is what lets
// the pieces saved for recovery keep up with the recording.
videoKeyFrameIntervalDuration: 2000,
} as MediaRecorderOptions);
const parts: Blob[] = [];
let n = 0;
rec.ondataavailable = (e) => {
if (!e.data.size) return;
parts.push(e.data);
// A copy that survives a closed or crashed tab. The recording itself is put together from `parts`,
// so a full disk here only costs the recovery copy.
void putChunk(id, n++, e.data).catch(() => {});
};
// Tells other tabs this recording is still being made, so they do not "recover" it.
let unlock = () => {};
void navigator.locks?.request(lockName(id), () => new Promise<void>((r) => (unlock = r)));
let finish = () => {};
const done = new Promise<Blob>((resolve) => {
finish = () => {
unlock();
resolve(new Blob(parts, { type: rec.mimeType || type }));
};
});
rec.onstop = () => finish();
rec.start(1000);
const began = performance.now();
let pausedAt = 0;
let pausedFor = 0;
return {
type: rec.mimeType || type || "",
elapsed: () => ((pausedAt || performance.now()) - began - pausedFor) / 1000,
pause() {
if (rec.state !== "recording") return;
rec.pause();
pausedAt = performance.now();
},
resume() {
if (rec.state !== "paused") return;
rec.resume();
pausedFor += performance.now() - pausedAt;
pausedAt = 0;
},
stop() {
if (rec.state === "inactive") return;
rec.stop();
// Some browsers never finish stopping when a track stalls; keep what was recorded rather than hang.
setTimeout(finish, 5000);
},
done,
};
}
Change history
Point store links to openapps.free
Bind agent address in policy
Link OpenApps pages and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync