Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion crates/compositor/src/pipeline_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,11 @@ impl Decoder {
_ if intent == DecodeIntent::Export && is_h264_8bit => false,
_ => true,
};
let r = if want_hw {
// A VideoToolbox device can be created even when this codec has no
// VideoToolbox decoder (notably VP8). In that case get_format selects
// CPU frames, but cpu=None would send them straight to the Metal
// compositor without its required NV12/CVPixelBuffer conversion.
let r = if want_hw && codec_supports_videotoolbox(dec) {
crate::ffi::av_hwdevice_ctx_create(
&mut hwdev,
crate::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
Expand Down Expand Up @@ -685,6 +689,43 @@ unsafe extern "C" fn get_hw_format_macos(
*pix_fmts
}

unsafe fn codec_supports_videotoolbox(codec: *const crate::ffi::AVCodec) -> bool {
if codec.is_null() {
return false;
}
let mut index = 0;
loop {
let config = crate::ffi::avcodec_get_hw_config(codec, index);
if config.is_null() {
return false;
}
if (*config).device_type == crate::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX
&& (*config).pix_fmt == crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX
{
return true;
}
index += 1;
}
}

#[cfg(test)]
mod webcam_decode_tests {
use super::codec_supports_videotoolbox;
use crate::ffi::{avcodec_find_decoder, AVCodecID};

#[test]
fn vp8_uses_cpu_presentation_while_h264_can_use_videotoolbox() {
unsafe {
let vp8 = avcodec_find_decoder(AVCodecID::AV_CODEC_ID_VP8);
let h264 = avcodec_find_decoder(AVCodecID::AV_CODEC_ID_H264);
assert!(!vp8.is_null());
assert!(!h264.is_null());
assert!(!codec_supports_videotoolbox(vp8));
assert!(codec_supports_videotoolbox(h264));
}
}
}

/// Source clip pour `run_composited_multi`. Mêmes champs que `pipeline_windows::ClipSource`.
pub struct ClipSource {
pub screen: String,
Expand Down
16 changes: 15 additions & 1 deletion electron/recording/webm-duration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe("webm-duration patching", () => {
return file.source;
}

it("patches small WebM files under 2MB in memory successfully", async () => {
it("patches small WebM files under 2MB successfully", async () => {
const webmBytes = createDummyWebm(true, 100);
const filePath = pathFor("small.webm");
await writeFile(filePath, webmBytes);
Expand All @@ -104,6 +104,20 @@ describe("webm-duration patching", () => {
expect(duration.getValue()).toBe(5000);
});

it("preserves successive unknown-sized Clusters in a small MediaRecorder file", async () => {
const header = createDummyWebm(false);
const cluster = Buffer.from("1f43b67501ffffffffffffffe78100a3858100008000", "hex");
const clusters = Buffer.concat([cluster, cluster]);
const filePath = pathFor("live-clusters.webm");
await writeFile(filePath, Buffer.concat([header, clusters]));

expect(await patchWebmDurationOnDisk(filePath, 2000)).toEqual({ patched: true });
const patched = await readFile(filePath);
const firstCluster = patched.indexOf(Buffer.from("1f43b675", "hex"));
expect(firstCluster).toBeGreaterThan(0);
expect(patched.subarray(firstCluster)).toEqual(clusters);
});

it("patches large WebM files over 2MB using the optimized streaming method successfully", async () => {
// Create a file larger than 2MB (e.g. 2.5MB cluster data) to trigger the optimized method
const webmBytes = createDummyWebm(true, 2.5 * 1024 * 1024);
Expand Down
10 changes: 4 additions & 6 deletions electron/recording/webm-duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,10 @@ export async function patchWebmDurationOnDisk(
let fileHandle: fs.FileHandle | null = null;
const tmpPath = `${filePath}.duration-patch.tmp`;
try {
const stat = await fs.stat(filePath);
if (stat.size < HEADER_CHUNK_SIZE) {
// Fallback: If file is smaller than 2MB, just use the in-memory method.
return await patchWebmDurationInMemory(filePath, durationMs);
}

// Use the header-only splice even for small recordings. The parser treats an
// unknown-sized Cluster as opaque bytes, then serializes it with a finite
// size enclosing every later Cluster. That corrupts MediaRecorder's stream.
// Keeping all Cluster bytes verbatim preserves their original boundaries.
fileHandle = await fs.open(filePath, "r");
const buffer = Buffer.alloc(HEADER_CHUNK_SIZE);
const { bytesRead } = await fileHandle.read(buffer, 0, HEADER_CHUNK_SIZE, 0);
Expand Down
8 changes: 3 additions & 5 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ import {
DEFAULT_WEBCAM_QUALITY,
WEBCAM_TARGET_FRAME_RATE,
type WebcamQualityId,
webcamBitrateForStream,
webcamPresetFor,
webcamQualityFrom,
webcamVideoConstraints,
} from "./webcamCaptureTarget";
import { webcamDeviceIdentityFrom } from "./webcamDeviceIdentity";
import { webcamRecorderOptions as cameraRecorderOptions } from "./webcamRecorderOptions";

const TARGET_FRAME_RATE = 60;
const MIN_FRAME_RATE = 30;
Expand Down Expand Up @@ -387,10 +387,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
* three and left the browser pipeline encoding a 2160p camera at the
* monitor's rate, capped well below what the frame needs.
*/
const webcamRecorderOptions = (stream: MediaStream | null): MediaRecorderOptions => ({
mimeType: selectMimeType(),
videoBitsPerSecond: webcamBitrateForStream(stream),
});
const webcamRecorderOptions = (stream: MediaStream | null): MediaRecorderOptions =>
cameraRecorderOptions(stream, window.electronAPI.getPlatform());

const selectMimeType = () => {
// H.264 first: hardware-accelerated, so sharp real-time output. AV1/VP9 are
Expand Down
33 changes: 33 additions & 0 deletions src/hooks/webcamRecorderOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { webcamRecorderOptions } from "./webcamRecorderOptions";

describe("webcam recorder options", () => {
it("avoids the macOS H.264 camera encoder even when it advertises support", () => {
expect(webcamRecorderOptions(null, "darwin", () => true).mimeType).toBe(
"video/webm;codecs=vp8",
);
});

it.each(["win32", "linux"])("keeps H.264 preferred on %s", (platform) => {
expect(webcamRecorderOptions(null, platform, () => true).mimeType).toBe(
"video/webm;codecs=h264",
);
});

it("negotiates another supported codec when VP8 is unavailable", () => {
expect(webcamRecorderOptions(null, "darwin", (type) => type.endsWith("vp9")).mimeType).toBe(
"video/webm;codecs=vp9",
);
});

it("lets MediaRecorder negotiate when no explicit codec is supported", () => {
expect(webcamRecorderOptions(null, "darwin", () => false)).not.toHaveProperty("mimeType");
});

it("uses the delivered camera dimensions for bitrate", () => {
const stream = {
getVideoTracks: () => [{ getSettings: () => ({ width: 1920, height: 1080 }) }],
} as unknown as MediaStream;
expect(webcamRecorderOptions(stream, "darwin", () => true).videoBitsPerSecond).toBe(16_000_000);
});
});
17 changes: 17 additions & 0 deletions src/hooks/webcamRecorderOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { webcamBitrateForStream } from "./webcamCaptureTarget";

/** Keep webcam encoding independent of the screen recorder's codec preference. */
export function webcamRecorderOptions(
stream: MediaStream | null,
platform: string,
isTypeSupported: (mimeType: string) => boolean = (type) => MediaRecorder.isTypeSupported(type),
): MediaRecorderOptions {
// Chromium's macOS H.264 camera encoder can stop delivering frames while the
// stream remains live. VP8 avoids that path; native screen capture still uses H.264.
const codecs = platform === "darwin" ? ["vp8", "vp9", "h264"] : ["h264", "vp8", "vp9"];
const mimeType = codecs.map((codec) => `video/webm;codecs=${codec}`).find(isTypeSupported);
return {
...(mimeType ? { mimeType } : {}),
videoBitsPerSecond: webcamBitrateForStream(stream),
};
}
Loading
Loading