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
41 changes: 41 additions & 0 deletions electron/hudOverlayBounds.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
getHudOverlayStaticBounds,
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
shouldExpandHudOverlayFallback,
Expand Down Expand Up @@ -76,6 +77,46 @@ describe("getHudOverlayWindowBounds", () => {
});
});

describe("getHudOverlayStaticBounds", () => {
const workArea = {
x: 120,
y: 40,
width: 1920,
height: 1040,
};

it("pre-sizes the Wayland fallback so HUD popovers are not clipped", () => {
const bounds = getHudOverlayStaticBounds(workArea, false, true);
expect(bounds).toEqual({
x: 650,
y: 540,
width: 860,
height: 540,
});
// Maximum menu card (400) + popover offset and HUD bar clearance.
expect(bounds.height).toBeGreaterThanOrEqual(400 + 16 + 96);
});

it("preserves compact bounds for a Linux X11 session", () => {
expect(getHudOverlayStaticBounds(workArea, false, false)).toEqual({
x: 650,
y: 920,
width: 860,
height: 160,
});
});

it("preserves full-work-area bounds on passthrough platforms", () => {
expect(getHudOverlayStaticBounds(workArea, true, true)).toEqual(workArea);
expect(getHudOverlayStaticBounds(workArea, true, false)).toEqual(workArea);
});

it("clamps the Wayland fallback to a small display work area", () => {
const smallWorkArea = { x: -100, y: 20, width: 640, height: 420 };
expect(getHudOverlayStaticBounds(smallWorkArea, false, true)).toEqual(smallWorkArea);
});
});

describe("resizeHudOverlayFallbackBounds", () => {
const workArea = {
x: 0,
Expand Down
21 changes: 21 additions & 0 deletions electron/hudOverlayBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ export function getHudOverlayWindowBounds(
};
}

/**
* Return stable creation/recompute bounds for the HUD. Wayland cannot honor
* the x/y half of a client-side resize reliably, so its non-passthrough HUD is
* created at the existing expanded height and kept there. That gives in-window
* popovers enough native surface to render without a resize/jump cycle.
*
* The gate is intentionally session-specific: Windows, macOS, and Linux/X11
* retain the existing bounds and runtime behavior.
*/
export function getHudOverlayStaticBounds(
workArea: HudOverlayWorkArea,
mousePassthroughSupported: boolean,
waylandSession: boolean,
): HudOverlayWorkArea {
return getHudOverlayWindowBounds(
workArea,
mousePassthroughSupported,
!mousePassthroughSupported && waylandSession,
);
}

export function shouldExpandHudOverlayFallback({
fallbackExpanded,
recordingActive,
Expand Down
26 changes: 26 additions & 0 deletions electron/hudOverlaySession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";

import { isWaylandSession } from "./hudOverlaySession";

describe("isWaylandSession", () => {
it("detects Wayland from XDG_SESSION_TYPE", () => {
expect(isWaylandSession({ XDG_SESSION_TYPE: "wayland" })).toBe(true);
expect(isWaylandSession({ XDG_SESSION_TYPE: " Wayland " })).toBe(true);
});

it("detects Wayland from WAYLAND_DISPLAY", () => {
expect(isWaylandSession({ WAYLAND_DISPLAY: "wayland-0" })).toBe(true);
});

it("does not treat X11 or empty variables as Wayland", () => {
expect(isWaylandSession({ XDG_SESSION_TYPE: "x11" })).toBe(false);
expect(isWaylandSession({ XDG_SESSION_TYPE: "", WAYLAND_DISPLAY: " " })).toBe(false);
expect(isWaylandSession({})).toBe(false);
});

it("prefers an exposed Wayland socket when both session hints exist", () => {
expect(isWaylandSession({ XDG_SESSION_TYPE: "x11", WAYLAND_DISPLAY: "wayland-0" })).toBe(
true,
);
});
});
15 changes: 15 additions & 0 deletions electron/hudOverlaySession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface HudOverlaySessionEnv {
XDG_SESSION_TYPE?: string | undefined;
WAYLAND_DISPLAY?: string | undefined;
[key: string]: string | undefined;
}

/**
* Detect whether the desktop session uses Wayland. The environment is stable
* for the Electron process lifetime and is injectable so platform gating can
* be covered without depending on the host running the tests.
*/
export function isWaylandSession(env: HudOverlaySessionEnv = process.env): boolean {
const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase();
return sessionType === "wayland" || Boolean(env.WAYLAND_DISPLAY?.trim());
}
16 changes: 11 additions & 5 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { app, BrowserWindow, ipcMain } from "electron";
import { supportsHudCaptureProtection } from "../src/lib/hudCaptureProtection";
import { USER_DATA_PATH } from "./appPaths";
import {
getHudOverlayStaticBounds,
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
shouldExpandHudOverlayFallback,
} from "./hudOverlayBounds";
import { isWaylandSession } from "./hudOverlaySession";
import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions";
import { getPackagedRendererBaseUrl } from "./rendererServer";

Expand Down Expand Up @@ -202,16 +204,20 @@ function getHudOverlayDisplay() {

function getHudOverlayBounds() {
const { workArea } = getHudOverlayDisplay();
const mousePassthroughSupported = isHudOverlayMousePassthroughSupported();
if (!mousePassthroughSupported && isWaylandSession()) {
// Popovers live inside this BrowserWindow and cannot paint outside its
// native surface. Keep the Wayland fallback at its expanded height from
// creation onward; runtime growth cannot preserve x/y on Wayland and can
// make the bottom-anchored bar jump or oscillate.
return getHudOverlayStaticBounds(workArea, mousePassthroughSupported, true);
}
const fallbackExpanded = shouldExpandHudOverlayFallback({
fallbackExpanded: hudOverlayFallbackExpanded,
recordingActive: hudOverlayRecordingActive,
webcamPreviewVisible: hudOverlayWebcamPreviewVisible,
});
return getHudOverlayWindowBounds(
workArea,
isHudOverlayMousePassthroughSupported(),
fallbackExpanded,
);
return getHudOverlayWindowBounds(workArea, mousePassthroughSupported, fallbackExpanded);
}

function applyHudOverlayBounds() {
Expand Down
43 changes: 42 additions & 1 deletion src/lib/exporter/modernVideoExporter.fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => {
streamingDecoderGetDemuxer: vi.fn(() => null),
streamingDecoderGetEffectiveDuration: vi.fn(() => 0),
streamingDecoderLoadMetadata: vi.fn(async () => videoInfo),
frameRendererConfigs: [] as Array<{ preferredRenderBackend?: "webgl" | "webgpu" }>,
frameRendererDestroy: vi.fn(),
frameRendererGetBackend: vi.fn(() => "webgl"),
frameRendererInitialize: vi.fn(async () => {}),
Expand All @@ -48,7 +49,8 @@ vi.mock("./streamingDecoder", () => ({
}));

vi.mock("./modernFrameRenderer", () => ({
FrameRenderer: vi.fn().mockImplementation(function () {
FrameRenderer: vi.fn().mockImplementation(function (config) {
mocks.frameRendererConfigs.push(config);
return {
destroy: mocks.frameRendererDestroy,
getRendererBackend: mocks.frameRendererGetBackend,
Expand Down Expand Up @@ -76,10 +78,49 @@ describe("ModernVideoExporter native fallback routing", () => {

afterEach(() => {
vi.clearAllMocks();
mocks.frameRendererConfigs.length = 0;
if (vi.isMockFunction(console.error)) console.error.mockRestore();
vi.unstubAllGlobals();
});

it.each([
{ configured: undefined, expected: "webgl" },
{ configured: "webgpu" as const, expected: "webgpu" },
])("uses $expected as the Lightning render backend when configured backend is $configured", async ({
configured,
expected,
}) => {
const exporter = new ModernVideoExporter({
videoUrl: "file:///recording.mp4",
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 8_000_000,
wallpaper: "#101010",
padding: 0,
borderRadius: 0,
backgroundBlur: 0,
shadowIntensity: 0,
showShadow: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
backendPreference: "webcodecs",
preferredRenderBackend: configured,
} as never) as unknown as {
export: () => Promise<{ success: boolean }>;
initializeEncoder: () => Promise<unknown>;
};
vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({
codec: "avc1.640034",
hardwareAcceleration: "prefer-hardware",
});

const result = await exporter.export();

expect(result.success).toBe(true);
expect(mocks.frameRendererConfigs).toHaveLength(1);
expect(mocks.frameRendererConfigs[0]?.preferredRenderBackend).toBe(expected);
}, 15_000);

it("removes failed native writes without creating an unhandled rejection", async () => {
const exporter = new ModernVideoExporter({} as never) as unknown as {
trackNativeWritePromise: (write: Promise<void>) => void;
Expand Down
4 changes: 3 additions & 1 deletion src/lib/exporter/modernVideoExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
} from "@/lib/wallpapers";
import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder";
import {
getDefaultLightningRenderBackend,
normalizeLightningRuntimePlatform,
shouldPreferNativeAutoBackend,
shouldPreferNativeStaticLayoutBeforeBreeze,
Expand Down Expand Up @@ -604,7 +605,8 @@ export class ModernVideoExporter {
timelineEffects: this.config.clipRegions !== undefined,
width: this.config.width,
height: this.config.height,
preferredRenderBackend: undefined,
preferredRenderBackend:
this.config.preferredRenderBackend ?? getDefaultLightningRenderBackend(),
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
Expand Down