fix(recording): prefer playable browser capture codecs (#275)

Verifies the selected recording MIME type can also be decoded by the
in-app <video> element via canPlayType(), so that codecs like av1 —
which MediaRecorder.isTypeSupported() reports as supported for encoding
but are not decodable on fresh Windows installs without the AV1 Video
Extension — are not chosen.

Also passes undefined (not an unsupported fallback string) into
new MediaRecorder() when no preferred type is supported, letting the
browser pick a default instead of throwing NotSupportedError.

Replaces the previous 'as ProcessedDesktopSource' cast with a properly
typed LINUX_PORTAL_SOURCE constant.

Co-authored-by: meiiie <meiiie@users.noreply.github.com>
This commit is contained in:
webadderall
2026-04-20 01:08:12 +10:00
co-authored by meiiie
parent 92bb1065a2
commit daf99c0387
3 changed files with 111 additions and 18 deletions
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { selectRecordingMimeType } from "./recordingMimeType";
describe("selectRecordingMimeType", () => {
it("prefers codecs the editor can play back", () => {
const mimeType = selectRecordingMimeType({
isTypeSupported: () => true,
canPlayType: (type) => {
if (type === "video/webm;codecs=vp9") {
return "probably";
}
if (type === "video/webm") {
return "maybe";
}
return "";
},
});
expect(mimeType).toBe("video/webm;codecs=vp9");
});
it("skips recorder-only codecs when playback support is missing", () => {
const mimeType = selectRecordingMimeType({
isTypeSupported: (type) =>
[
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
].includes(type),
canPlayType: (type) => (type === "video/webm;codecs=vp8" ? "probably" : ""),
});
expect(mimeType).toBe("video/webm;codecs=vp8");
});
it("falls back to the first supported codec when playback probing is unavailable", () => {
const mimeType = selectRecordingMimeType({
isTypeSupported: (type) =>
[
"video/webm;codecs=av1",
"video/webm;codecs=h264",
].includes(type),
canPlayType: () => "",
});
expect(mimeType).toBe("video/webm;codecs=av1");
});
it("returns undefined when no preferred mime type is supported", () => {
const mimeType = selectRecordingMimeType({
isTypeSupported: () => false,
canPlayType: () => "",
});
expect(mimeType).toBeUndefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
const RECORDING_MIME_TYPE_PREFERENCES = [
"video/webm;codecs=vp9",
"video/webm",
"video/webm;codecs=vp8",
"video/webm;codecs=av1",
"video/webm;codecs=h264",
] as const;
type MimeTypeSelectorOptions = {
isTypeSupported?: (type: string) => boolean;
canPlayType?: (type: string) => string;
};
export function selectRecordingMimeType(
options: MimeTypeSelectorOptions = {},
): string | undefined {
const isTypeSupported =
options.isTypeSupported ?? ((type: string) => MediaRecorder.isTypeSupported(type));
const canPlayType =
options.canPlayType ??
((type: string) => document.createElement("video").canPlayType(type));
const supportedTypes = RECORDING_MIME_TYPE_PREFERENCES.filter((type) =>
isTypeSupported(type),
);
const playableType = supportedTypes.find((type) => canPlayType(type) !== "");
return playableType ?? supportedTypes[0];
}
+24 -18
View File
@@ -2,6 +2,7 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming";
import { selectRecordingMimeType } from "./recordingMimeType";
const TARGET_FRAME_RATE = 60;
const TARGET_WIDTH = 3840;
@@ -32,6 +33,14 @@ const WEBCAM_WIDTH = 1280;
const WEBCAM_HEIGHT = 720;
const WEBCAM_FRAME_RATE = 30;
const WEBCAM_SUFFIX = "-webcam";
const LINUX_PORTAL_SOURCE: ProcessedDesktopSource = {
id: "screen:linux-portal",
name: "Linux Portal",
display_id: "",
thumbnail: null,
appIcon: null,
sourceType: "screen",
};
type PauseSegment = {
startMs: number;
@@ -262,15 +271,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, []);
const selectMimeType = useCallback(() => {
const preferred = [
"video/webm;codecs=av1",
"video/webm;codecs=h264",
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm",
];
return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
return selectRecordingMimeType();
}, []);
const computeBitrate = (width: number, height: number) => {
@@ -542,8 +543,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
pendingWebcamPathPromise.current = webcamStopPromise.current;
const recorder = new MediaRecorder(webcamStream.current, {
mimeType,
videoBitsPerSecond: WEBCAM_BITRATE,
...(mimeType ? { mimeType } : {}),
});
webcamRecorder.current = recorder;
@@ -570,7 +571,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
0,
getRecordingDurationMs(Date.now()) - webcamTimeOffsetMs.current,
);
const webcamBlob = new Blob(webcamChunks.current, { type: mimeType });
const webcamBlobType = recorder.mimeType || mimeType;
const webcamBlob = new Blob(
webcamChunks.current,
webcamBlobType ? { type: webcamBlobType } : undefined,
);
webcamChunks.current = [];
const fixedBlob = await fixWebmDuration(webcamBlob, duration);
const arrayBuffer = await fixedBlob.arrayBuffer();
@@ -838,10 +843,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const platform = await window.electronAPI.getPlatform();
const existingSource = await window.electronAPI.getSelectedSource();
const selectedSource =
existingSource ??
(platform === "linux"
? { id: "screen:linux-portal", name: "Linux Portal" } as ProcessedDesktopSource
: null);
existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null);
if (!selectedSource) {
alert("Please select a source to record");
return;
@@ -1208,7 +1210,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const mimeType = selectMimeType();
console.log(
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType ?? "browser default"} / ${Math.round(
videoBitsPerSecond / BITS_PER_MEGABIT,
)} Mbps`,
);
@@ -1216,8 +1218,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
chunks.current = [];
const hasAudio = stream.current.getAudioTracks().length > 0;
const recorder = new MediaRecorder(stream.current, {
mimeType,
videoBitsPerSecond,
...(mimeType ? { mimeType } : {}),
...(hasAudio
? {
audioBitsPerSecond: systemAudioIncluded
@@ -1239,7 +1241,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const duration = getRecordingDurationMs(Date.now());
const recordedChunks = chunks.current;
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
const recordingBlobType = recorder.mimeType || mimeType;
const buggyBlob = new Blob(
recordedChunks,
recordingBlobType ? { type: recordingBlobType } : undefined,
);
chunks.current = [];
const timestamp = recordingSessionTimestamp.current ?? Date.now();
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;