record-free.openapps.xyz

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.
Owned app · rev 1c51a6b34b9a
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.
← All files
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
1c51a6b34b · 18 hours ago
Bind agent address in policy
50f7c610b7 · 19 hours ago
Link OpenApps pages and add contribution strip
99dc7c6d18 · 19 hours ago
Bounded source sync
16c8400957 · 2 days ago
Bounded source sync
c07b7aea64 · 2 days ago
Bounded source sync
f19b8997d3 · 2 days ago