Merge main into feature/click-effects-auto-zoom-fps-fixes

This commit is contained in:
webadderall
2026-05-28 21:47:36 +10:00
43 changed files with 1415 additions and 573 deletions
+118 -117
View File
@@ -21,9 +21,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useVideoDevices } from "../../hooks/useVideoDevices";
import { Button } from "../ui/button";
import { HudInteractionContext } from "./contexts/HudInteractionContext";
import {
canToggleFloatingWebcamPreview,
} from "./floatingWebcamPreview";
import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState";
import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions";
@@ -32,7 +30,10 @@ import { useRecordingTimer } from "./hooks/useRecordingTimer";
import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay";
import styles from "./LaunchWindow.module.css";
import { CountdownPopover } from "./popovers/CountdownPopover";
import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator";
import {
LaunchPopoverCoordinatorProvider,
useLaunchPopoverCoordinator,
} from "./popovers/LaunchPopoverCoordinator";
import { MicPopover } from "./popovers/MicPopover";
import { MorePopover } from "./popovers/MorePopover";
import { ProjectPopover } from "./popovers/ProjectPopover";
@@ -83,7 +84,6 @@ function LaunchWindowContent() {
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
const {
selectedSource,
hasSelectedSource,
@@ -166,12 +166,13 @@ function LaunchWindowContent() {
recordingWebcamPreviewContainerRef,
});
const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = useLaunchHudInteractionState({
openId,
isHudDraggingRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
});
const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } =
useLaunchHudInteractionState({
openId,
isHudDraggingRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
});
useEffect(() => {
let mounted = true;
@@ -195,7 +196,6 @@ function LaunchWindowContent() {
ease: [0.22, 1, 0.36, 1] as const,
};
const recordingControls = (
<RecordingControls
paused={paused}
@@ -269,7 +269,11 @@ function LaunchWindowContent() {
}
className={microphoneEnabled ? styles.ibActive : ""}
>
{microphoneEnabled ? <MicrophoneIcon size={18} /> : <MicrophoneSlashIcon size={18} />}
{microphoneEnabled ? (
<MicrophoneIcon size={18} />
) : (
<MicrophoneSlashIcon size={18} />
)}
</Button>
}
/>
@@ -282,9 +286,7 @@ function LaunchWindowContent() {
hudOverlayMousePassthroughSupported,
)}
showFloatingWebcamPreview={showFloatingWebcamPreview}
onToggleFloatingPreview={() =>
setShowFloatingWebcamPreview((current) => !current)
}
onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)}
showWebcamControls={showWebcamControls}
setWebcamPreviewNode={setWebcamPreviewNode}
videoDevices={videoDevices}
@@ -307,7 +309,11 @@ function LaunchWindowContent() {
}
className={webcamEnabled ? styles.ibActive : ""}
>
{webcamEnabled ? <VideoCameraIcon size={18} /> : <VideoCameraSlashIcon size={18} />}
{webcamEnabled ? (
<VideoCameraIcon size={18} />
) : (
<VideoCameraSlashIcon size={18} />
)}
</Button>
}
/>
@@ -328,7 +334,6 @@ function LaunchWindowContent() {
}
/>
<button
type="button"
className={`${styles.recBtn} ${styles.electronNoDrag}`}
@@ -338,7 +343,7 @@ function LaunchWindowContent() {
: () => {
beginInteractiveHudAction();
requestOpen("sources");
}
}
}
disabled={countdownActive}
title={t("recording.record")}
@@ -382,12 +387,7 @@ function LaunchWindowContent() {
}}
appVersion={appVersion}
trigger={
<Button
variant="ghost"
size="icon"
iconSize="lg"
title={t("recording.more")}
>
<Button variant="ghost" size="icon" iconSize="lg" title={t("recording.more")}>
<DotsThreeVerticalIcon size={18} />
</Button>
}
@@ -430,112 +430,113 @@ function LaunchWindowContent() {
platform === "linux" || hudOverlayMousePassthroughSupported === false;
return (
<HudInteractionContext.Provider value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}>
<HudInteractionContext.Provider
value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}
>
<div
className="w-full flex justify-center bg-transparent overflow-visible items-end pb-5 pointer-events-none"
style={{ height: "100vh" }}
>
<div
ref={hudContentRef}
className="flex items-center overflow-visible flex-col-reverse pointer-events-none"
>
<div
className="flex flex-col items-center pointer-events-auto p-2"
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
ref={hudContentRef}
className="flex items-center overflow-visible flex-col-reverse pointer-events-none"
>
<div
ref={hudBarTransformRef}
style={{
transform: `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`,
}}
className="flex flex-col items-center pointer-events-auto p-2"
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
>
<motion.div
ref={hudBarRef}
layout={!showRecordingWebcamPreview && !isHudDragging}
transition={hudStateTransition}
className={`${styles.bar} launch-theme mb-2`}
>
<div
// Linux compositors and non-passthrough Windows fallback windows
// need native window dragging; the JS drag path only translates
// content inside the HUD window.
className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${
useNativeHudBarDrag ? styles.electronDrag : ""
}`}
onPointerDown={handleHudBarPointerDown}
onPointerMove={handleHudBarPointerMove}
onPointerUp={handleHudBarPointerUp}
onPointerCancel={handleHudBarPointerUp}
>
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={hudMode}
layout={!showRecordingWebcamPreview && !isHudDragging}
className={styles.barState}
initial={{
opacity: 0,
y: 10,
scale: 0.985,
filter: "blur(8px)",
}}
animate={{
opacity: 1,
y: 0,
scale: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -10,
scale: 0.985,
filter: "blur(6px)",
}}
transition={hudStateTransition}
>
{finalizing
? finalizingControls
: recording
? recordingControls
: idleControls}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</div>
{showRecordingWebcamPreview && (
<div
ref={recordingWebcamPreviewContainerRef}
className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag} pointer-events-auto`}
data-hud-interactive
title={t("recording.webcam")}
ref={hudBarTransformRef}
style={{
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
transform: `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`,
}}
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
onPointerDown={handleWebcamPreviewPointerDown}
onPointerMove={handleWebcamPreviewPointerMove}
onPointerUp={handleWebcamPreviewPointerUp}
onPointerCancel={handleWebcamPreviewPointerUp}
>
<video
ref={setRecordingWebcamPreviewNode}
className={styles.recordingWebcamPreviewVideo}
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
)}
</div>
<motion.div
ref={hudBarRef}
layout={!showRecordingWebcamPreview && !isHudDragging}
transition={hudStateTransition}
className={`${styles.bar} launch-theme mb-2`}
>
<div
// Linux compositors and non-passthrough Windows fallback windows
// need native window dragging; the JS drag path only translates
// content inside the HUD window.
className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${
useNativeHudBarDrag ? styles.electronDrag : ""
}`}
onPointerDown={handleHudBarPointerDown}
onPointerMove={handleHudBarPointerMove}
onPointerUp={handleHudBarPointerUp}
onPointerCancel={handleHudBarPointerUp}
>
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={hudMode}
layout={!showRecordingWebcamPreview && !isHudDragging}
className={styles.barState}
initial={{
opacity: 0,
y: 10,
scale: 0.985,
filter: "blur(8px)",
}}
animate={{
opacity: 1,
y: 0,
scale: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -10,
scale: 0.985,
filter: "blur(6px)",
}}
transition={hudStateTransition}
>
{finalizing
? finalizingControls
: recording
? recordingControls
: idleControls}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</div>
{showRecordingWebcamPreview && (
<div
ref={recordingWebcamPreviewContainerRef}
className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag} pointer-events-auto`}
data-hud-interactive
title={t("recording.webcam")}
style={{
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
}}
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
onPointerDown={handleWebcamPreviewPointerDown}
onPointerMove={handleWebcamPreviewPointerMove}
onPointerUp={handleWebcamPreviewPointerUp}
onPointerCancel={handleWebcamPreviewPointerUp}
>
<video
ref={setRecordingWebcamPreviewNode}
className={styles.recordingWebcamPreviewVideo}
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
)}
</div>
</div>
</div>
</div>
</HudInteractionContext.Provider>
);
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, type MouseEvent, type RefObject } from "react";
import { type MouseEvent, type RefObject, useCallback, useEffect, useRef } from "react";
export function useLaunchHudInteractionState({
openId,
@@ -32,7 +32,7 @@ export function useLaunchHudInteractionState({
const target = e.target as HTMLElement | null;
if (!target) return;
const isInteractive = !!target.closest(
".pointer-events-auto, [data-hud-interactive], [data-radix-popper-content-wrapper]"
".pointer-events-auto, [data-hud-interactive], [data-radix-popper-content-wrapper]",
);
if (isInteractive) {
@@ -70,27 +70,30 @@ export function useLaunchHudInteractionState({
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}, []);
const handleHudMouseLeave = useCallback((event: MouseEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return;
}
isMouseOverHudRef.current = false;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (
!isHudDraggingRef.current &&
!isWebcamPreviewDraggingRef.current &&
!webcamPreviewDragStartRef.current &&
!isMouseOverHudRef.current
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
const handleHudMouseLeave = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return;
}
}, 300);
}, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]);
isMouseOverHudRef.current = false;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (
!isHudDraggingRef.current &&
!isWebcamPreviewDraggingRef.current &&
!webcamPreviewDragStartRef.current &&
!isMouseOverHudRef.current
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 300);
},
[isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef],
);
return {
handleHudMouseEnter,
@@ -10,13 +10,13 @@ export function KeyboardShortcutsHelp() {
const t = useScopedT("editor");
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
pan: "Shift + Scroll",
zoom: "Ctrl + Scroll",
});
useEffect(() => {
Promise.all([
formatShortcut(["shift", "mod", "Scroll"]),
formatShortcut(["shift", "Scroll"]),
formatShortcut(["mod", "Scroll"]),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
@@ -3545,6 +3545,7 @@ export function SettingsPanel({
<Switch
checked={showCursor}
onCheckedChange={onShowCursorChange}
disabled={nativeCaptureUnavailableSession}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</label>
@@ -3553,11 +3554,20 @@ export function SettingsPanel({
<Switch
checked={loopCursor}
onCheckedChange={onLoopCursorChange}
disabled={nativeCaptureUnavailableSession}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</label>
</div>
</div>
{nativeCaptureUnavailableSession ? (
<div className="rounded-lg border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-[10px] leading-4 text-muted-foreground">
{tSettings(
"effects.cursorOverlayUnavailable",
"Cursor overlay is unavailable for this recording because the captured video already contains the system cursor.",
)}
</div>
) : null}
<div className="flex flex-col gap-1.5">
<div className="space-y-1.5">
<ToggleGroup
+2 -2
View File
@@ -149,13 +149,13 @@ export function KeyboardShortcutsDialog({
const { shortcuts, isMac, openConfig } = useShortcuts();
const t = useScopedT("editor");
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
pan: "Shift + Scroll",
zoom: "Ctrl + Scroll",
});
useEffect(() => {
Promise.all([
formatShortcut(["shift", "mod", "Scroll"]),
formatShortcut(["shift", "Scroll"]),
formatShortcut(["mod", "Scroll"]),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
+54 -5
View File
@@ -163,7 +163,10 @@ import {
RECORDLY_ISSUES_URL,
} from "./TutorialHelp";
import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor";
import { normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils";
import {
normalizeCursorTelemetry,
shouldAutoApplyFreshRecordingZoomsForSource,
} from "./timeline/zoomSuggestionUtils";
import {
type AnnotationRegion,
type AudioRegion,
@@ -1253,10 +1256,18 @@ export default function VideoEditor() {
setExportProgress(resolveSavingExportProgress);
}, []);
const handleShowCursorChange = useCallback((nextShowCursor: boolean) => {
setSessionShowCursorOverride(null);
setShowCursor(nextShowCursor);
}, []);
const handleShowCursorChange = useCallback(
(nextShowCursor: boolean) => {
if (nextShowCursor && sessionNativeCaptureUnavailable) {
setNativeCaptureUnavailableModalOpen(true);
return;
}
setSessionShowCursorOverride(null);
setShowCursor(nextShowCursor);
},
[sessionNativeCaptureUnavailable],
);
const remountPreview = useCallback(() => {
setIsPreviewReady(false);
@@ -3401,6 +3412,23 @@ export default function VideoEditor() {
);
useEffect(() => {
if (
videoPath &&
pendingFreshRecordingAutoZoomPathRef.current === videoPath &&
isPreviewReady &&
!shouldAutoApplyFreshRecordingZoomsForSource(
videoPlaybackRef.current?.video?.videoWidth,
videoPlaybackRef.current?.video?.videoHeight,
)
) {
pendingFreshRecordingAutoZoomPathRef.current = null;
if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) {
window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current);
pendingFreshRecordingAutoSuggestTimeoutRef.current = null;
}
return;
}
if (
!videoPath ||
loading ||
@@ -3459,6 +3487,27 @@ export default function VideoEditor() {
zoomRegions,
]);
useEffect(() => {
if (
!videoPath ||
!isPreviewReady ||
zoomRegions.length === 0 ||
autoSuggestedVideoPathRef.current !== videoPath ||
shouldAutoApplyFreshRecordingZoomsForSource(
videoPlaybackRef.current?.video?.videoWidth,
videoPlaybackRef.current?.video?.videoHeight,
)
) {
return;
}
autoSuggestedVideoPathRef.current = null;
setZoomRegions((prev) => {
const next = prev.filter((region) => region.mode !== "auto");
return next.length === prev.length ? prev : next;
});
}, [videoPath, isPreviewReady, zoomRegions]);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
setZoomRegions((prev) =>
prev.map((region) =>
@@ -11,11 +11,7 @@ import { TimelineContext } from "dnd-timeline";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useCallback, useRef } from "react";
import type { TimelineRegionSpan } from "../../core/timelineTypes";
import {
clampRange,
resolveDragEnd,
resolveResizeEnd,
} from "../../dnd/engine";
import { clampRange, resolveDragEnd, resolveResizeEnd } from "../../dnd/engine";
interface TimelineWrapperProps {
children: ReactNode;
@@ -162,9 +158,10 @@ export default function TimelineWrapper({
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
onLiveSpanPreviewChange?.(event.active.id as string, span ?? null);
// dnd-timeline mutates the active item's DOM during resize; React preview
// renders here can reset that inline width/edge position and make trims stutter.
},
[onLiveSpanPreviewChange, showTooltip],
[showTooltip],
);
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { resolveTimelineWheelPanDeltaPx } from "./useTimelineRange";
describe("resolveTimelineWheelPanDeltaPx", () => {
it("uses trackpad horizontal wheel movement for timeline panning", () => {
expect(
resolveTimelineWheelPanDeltaPx({
deltaX: 24,
deltaY: 0,
deltaMode: 0,
}),
).toBe(24);
});
it("uses shifted vertical wheel movement for timeline panning", () => {
expect(
resolveTimelineWheelPanDeltaPx({
deltaX: 0,
deltaY: 3,
deltaMode: 1,
shiftKey: true,
}),
).toBe(48);
});
it("keeps ctrl wheel available for timeline zoom unless shift is also held", () => {
expect(
resolveTimelineWheelPanDeltaPx({
deltaX: 0,
deltaY: 3,
deltaMode: 1,
ctrlKey: true,
}),
).toBe(0);
expect(
resolveTimelineWheelPanDeltaPx({
deltaX: 0,
deltaY: 3,
deltaMode: 1,
ctrlKey: true,
shiftKey: true,
}),
).toBe(48);
});
it("uses regular wheel movement when the timeline has no vertical overflow", () => {
expect(
resolveTimelineWheelPanDeltaPx({
deltaX: 0,
deltaY: 20,
deltaMode: 0,
canScrollVertically: false,
}),
).toBe(20);
});
});
@@ -7,6 +7,40 @@ interface UseTimelineRangeParams {
timelineContainerRef: RefObject<HTMLDivElement>;
}
export interface TimelineWheelPanDeltaInput {
deltaX: number;
deltaY: number;
deltaMode: number;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
canScrollVertically?: boolean;
}
export function resolveTimelineWheelPanDeltaPx({
deltaX,
deltaY,
deltaMode,
shiftKey = false,
ctrlKey = false,
metaKey = false,
canScrollVertically = true,
}: TimelineWheelPanDeltaInput) {
if ((ctrlKey || metaKey) && !shiftKey) {
return 0;
}
if (Math.abs(deltaX) > 0) {
return normalizeWheelDeltaToPixels(deltaX, deltaMode);
}
if ((shiftKey || !canScrollVertically) && Math.abs(deltaY) > 0) {
return normalizeWheelDeltaToPixels(deltaY, deltaMode);
}
return 0;
}
export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineRangeParams) {
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
@@ -42,29 +76,34 @@ export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineR
const handleTimelineWheel = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
if (event.ctrlKey || event.metaKey || totalMs <= 0) {
if (((event.ctrlKey || event.metaKey) && !event.shiftKey) || totalMs <= 0) {
return;
}
const rawHorizontalDelta =
Math.abs(event.deltaX) > 0
? event.deltaX
: event.shiftKey && Math.abs(event.deltaY) > 0
? event.deltaY
: 0;
const container = timelineContainerRef.current;
const horizontalDeltaPx = resolveTimelineWheelPanDeltaPx({
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaMode: event.deltaMode,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
canScrollVertically: container
? container.scrollHeight > container.clientHeight + 1
: true,
});
if (rawHorizontalDelta === 0) {
if (horizontalDeltaPx === 0) {
return;
}
const containerWidth = timelineContainerRef.current?.clientWidth ?? 0;
const containerWidth = container?.clientWidth ?? 0;
const visibleRangeMs = clampedRange.end - clampedRange.start;
if (containerWidth <= 0 || visibleRangeMs <= 0) {
return;
}
event.preventDefault();
const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode);
const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs;
panTimelineRange(deltaMs);
},
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import type { CursorTelemetryPoint } from "../types";
import {
buildInteractionZoomSuggestions,
CLICK_CLUSTER_MERGE_GAP_MS,
CLICK_CLUSTER_PAD_MS,
buildInteractionZoomSuggestions,
shouldAutoApplyFreshRecordingZoomsForSource,
} from "./zoomSuggestionUtils";
import type { CursorTelemetryPoint } from "../types";
function makeClick(
timeMs: number,
@@ -20,19 +21,28 @@ function makeMove(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint {
}
/** Wraps click samples with surrounding move events to mimic real mixed telemetry. */
function withMoves(
clicks: CursorTelemetryPoint[],
totalMs: number,
): CursorTelemetryPoint[] {
return [
makeMove(0),
...clicks,
makeMove(totalMs),
];
function withMoves(clicks: CursorTelemetryPoint[], totalMs: number): CursorTelemetryPoint[] {
return [makeMove(0), ...clicks, makeMove(totalMs)];
}
const TOTAL_MS = 30_000;
describe("shouldAutoApplyFreshRecordingZoomsForSource", () => {
it("allows automatic fresh-recording zooms for landscape captures", () => {
expect(shouldAutoApplyFreshRecordingZoomsForSource(1920, 1080)).toBe(true);
expect(shouldAutoApplyFreshRecordingZoomsForSource(1280, 960)).toBe(true);
});
it("blocks automatic fresh-recording zooms for narrow or near-square captures", () => {
expect(shouldAutoApplyFreshRecordingZoomsForSource(960, 1020)).toBe(false);
expect(shouldAutoApplyFreshRecordingZoomsForSource(1080, 1080)).toBe(false);
});
it("does not block when source dimensions are not available yet", () => {
expect(shouldAutoApplyFreshRecordingZoomsForSource()).toBe(true);
});
});
describe("buildInteractionZoomSuggestions (click-cluster logic)", () => {
it("creates one zoom track for a single isolated click with 500ms padding", () => {
const telemetry = withMoves([makeClick(5_000)], TOTAL_MS);
@@ -62,23 +72,23 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => {
expect(result.suggestions).toHaveLength(1);
});
it.each(["right-click", "middle-click"] as const)(
"accepts %s telemetry like a standard click",
(interactionType) => {
const result = buildInteractionZoomSuggestions({
cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS),
totalMs: TOTAL_MS,
defaultDurationMs: 3_000,
});
it.each([
"right-click",
"middle-click",
] as const)("accepts %s telemetry like a standard click", (interactionType) => {
const result = buildInteractionZoomSuggestions({
cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS),
totalMs: TOTAL_MS,
defaultDurationMs: 3_000,
});
expect(result.status).toBe("ok");
expect(result.suggestions).toHaveLength(1);
expect(result.status).toBe("ok");
expect(result.suggestions).toHaveLength(1);
const [suggestion] = result.suggestions;
expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS);
expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS);
},
);
const [suggestion] = result.suggestions;
expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS);
expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS);
});
it("merges two clicks within 2500ms into one zoom track", () => {
const telemetry = withMoves(
@@ -3,6 +3,7 @@ import type { CursorTelemetryPoint, ZoomFocus } from "../types";
export const MIN_DWELL_DURATION_MS = 450;
export const MAX_DWELL_DURATION_MS = 2600;
export const DWELL_MOVE_THRESHOLD = 0.02;
export const MIN_FRESH_RECORDING_AUTO_ZOOM_SOURCE_ASPECT_RATIO = 1.2;
export interface ZoomDwellCandidate {
centerTimeMs: number;
@@ -39,6 +40,25 @@ export interface InteractionZoomSuggestionResult {
suggestions: SuggestedZoomRegion[];
}
export function shouldAutoApplyFreshRecordingZoomsForSource(
sourceWidth?: number,
sourceHeight?: number,
): boolean {
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
(sourceWidth ?? 0) <= 0 ||
(sourceHeight ?? 0) <= 0
) {
return true;
}
return (
(sourceWidth as number) / (sourceHeight as number) >=
MIN_FRESH_RECORDING_AUTO_ZOOM_SOURCE_ASPECT_RATIO
);
}
/** Max gap between consecutive clicks before they are split into separate zoom clusters. */
export const CLICK_CLUSTER_MERGE_GAP_MS = 2500;
/** Padding added before the first click and after the last click in a cluster. */
+9 -18
View File
@@ -7,15 +7,15 @@ import {
} from "./recordingMimeType";
describe("selectRecordingMimeType", () => {
it("prefers codecs the editor can play back", () => {
it("keeps browser screen captures in WebM/H.264 when supported", () => {
const mimeType = selectRecordingMimeType({
isTypeSupported: () => true,
canPlayType: (type) => {
if (type === "video/webm;codecs=vp9") {
if (type === "video/webm;codecs=h264") {
return "probably";
}
if (type === "video/webm") {
if (type === "video/webm;codecs=vp9") {
return "maybe";
}
@@ -23,16 +23,13 @@ describe("selectRecordingMimeType", () => {
},
});
expect(mimeType).toBe("video/webm;codecs=vp9");
expect(mimeType).toBe("video/webm;codecs=h264");
});
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),
["video/webm;codecs=vp9", "video/webm;codecs=vp8"].includes(type),
canPlayType: (type) => (type === "video/webm;codecs=vp8" ? "probably" : ""),
});
@@ -42,14 +39,11 @@ describe("selectRecordingMimeType", () => {
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),
["video/webm;codecs=av1", "video/webm;codecs=h264"].includes(type),
canPlayType: () => "",
});
expect(mimeType).toBe("video/webm;codecs=av1");
expect(mimeType).toBe("video/webm;codecs=h264");
});
it("returns undefined when no preferred mime type is supported", () => {
@@ -64,9 +58,7 @@ describe("selectRecordingMimeType", () => {
it("prefers MP4/H.264 for webcam captures when supported", () => {
const mimeType = selectWebcamRecordingMimeType({
isTypeSupported: (type) =>
["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9"].includes(
type,
),
["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9"].includes(type),
canPlayType: () => "probably",
});
@@ -75,8 +67,7 @@ describe("selectRecordingMimeType", () => {
it("falls back to WebM webcam capture when MP4 is unavailable", () => {
const mimeType = selectWebcamRecordingMimeType({
isTypeSupported: (type) =>
["video/webm;codecs=vp9", "video/webm"].includes(type),
isTypeSupported: (type) => ["video/webm;codecs=vp9", "video/webm"].includes(type),
canPlayType: () => "probably",
});
+5 -5
View File
@@ -1,9 +1,9 @@
const RECORDING_MIME_TYPE_PREFERENCES = [
"video/webm;codecs=h264",
"video/webm;codecs=vp9",
"video/webm",
"video/webm;codecs=vp8",
"video/webm;codecs=av1",
"video/webm;codecs=h264",
] as const;
const WEBCAM_RECORDING_MIME_TYPE_PREFERENCES = [
@@ -38,9 +38,7 @@ function selectMimeTypeFromPreferences(
return playableType ?? supportedTypes[0];
}
export function selectRecordingMimeType(
options: MimeTypeSelectorOptions = {},
): string | undefined {
export function selectRecordingMimeType(options: MimeTypeSelectorOptions = {}): string | undefined {
return selectMimeTypeFromPreferences(RECORDING_MIME_TYPE_PREFERENCES, options);
}
@@ -54,6 +52,8 @@ export function isWebmMimeType(mimeType: string | undefined | null): boolean {
return /^video\/webm(?:[;\s]|$)/i.test(mimeType ?? "");
}
export function getVideoExtensionForMimeType(mimeType: string | undefined | null): ".mp4" | ".webm" {
export function getVideoExtensionForMimeType(
mimeType: string | undefined | null,
): ".mp4" | ".webm" {
return /^video\/mp4(?:[;\s]|$)/i.test(mimeType ?? "") ? ".mp4" : ".webm";
}
+106 -7
View File
@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createBrowserRecordingOptions,
createProcessedMicrophoneConstraints,
getScreenCaptureCursorSetting,
normalizeBrowserMicrophoneProfile,
resolveBrowserCaptureCursorPolicy,
resolveLinuxPortalCursorPresentation,
shouldUseNativeWindowsCaptureForSource,
} from "./useScreenRecorder";
type RecordingState = "inactive" | "recording" | "paused";
@@ -31,12 +35,12 @@ function createMockMediaRecorder(initialState: RecordingState = "inactive") {
}
describe("createProcessedMicrophoneConstraints", () => {
it("requests browser voice processing without AGC for the default microphone", () => {
it("requests browser voice processing with AGC for the default microphone", () => {
expect(createProcessedMicrophoneConstraints()).toEqual({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false,
autoGainControl: true,
channelCount: { ideal: 1 },
sampleRate: { ideal: 48000 },
},
@@ -44,13 +48,13 @@ describe("createProcessedMicrophoneConstraints", () => {
});
});
it("keeps no-AGC voice processing when a specific microphone is selected", () => {
it("keeps default voice processing when a specific microphone is selected", () => {
expect(createProcessedMicrophoneConstraints("device-123")).toMatchObject({
audio: {
deviceId: { exact: "device-123" },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false,
autoGainControl: true,
channelCount: { ideal: 1 },
sampleRate: { ideal: 48000 },
},
@@ -102,10 +106,38 @@ describe("createProcessedMicrophoneConstraints", () => {
});
});
it("normalizes invalid lab microphone profiles to production no-AGC processing", () => {
it("normalizes invalid lab microphone profiles to production voice processing", () => {
expect(normalizeBrowserMicrophoneProfile("RAW")).toBe("raw");
expect(normalizeBrowserMicrophoneProfile("unknown")).toBe("no-agc");
expect(normalizeBrowserMicrophoneProfile(null)).toBe("no-agc");
expect(normalizeBrowserMicrophoneProfile("unknown")).toBe("processed");
expect(normalizeBrowserMicrophoneProfile(null)).toBe("processed");
});
});
describe("createBrowserRecordingOptions", () => {
it("sets an aggregate bitrate target for browser screen recordings", () => {
expect(
createBrowserRecordingOptions({
audioBitsPerSecond: 128_000,
mimeType: "video/webm;codecs=vp9",
videoBitsPerSecond: 30_600_000,
}),
).toEqual({
audioBitsPerSecond: 128_000,
bitsPerSecond: 30_728_000,
mimeType: "video/webm;codecs=vp9",
videoBitsPerSecond: 30_600_000,
});
});
it("keeps video-only recordings on the requested video budget", () => {
expect(
createBrowserRecordingOptions({
videoBitsPerSecond: 30_600_000,
}),
).toEqual({
bitsPerSecond: 30_600_000,
videoBitsPerSecond: 30_600_000,
});
});
});
@@ -115,6 +147,7 @@ describe("resolveBrowserCaptureCursorPolicy", () => {
streamCursor: "never",
hideOsCursorBeforeRecording: true,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: false,
});
});
@@ -125,8 +158,74 @@ describe("resolveBrowserCaptureCursorPolicy", () => {
streamCursor: "always",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
});
it("does not fake OS cursor hiding on Linux portal capture", () => {
expect(resolveBrowserCaptureCursorPolicy({ platform: "linux" })).toEqual({
streamCursor: "never",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
});
});
describe("resolveLinuxPortalCursorPresentation", () => {
it("enables the Recordly overlay only when the portal confirms cursor-hidden capture", () => {
expect(
resolveLinuxPortalCursorPresentation({
requestedCursor: "never",
actualCursor: "never",
}),
).toEqual({
hideEditorOverlayCursorByDefault: false,
nativeCaptureUnavailable: false,
});
});
it("keeps the overlay disabled when the portal embeds or omits cursor settings", () => {
expect(
resolveLinuxPortalCursorPresentation({
requestedCursor: "never",
actualCursor: "always",
}),
).toEqual({
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
expect(
resolveLinuxPortalCursorPresentation({
requestedCursor: "never",
actualCursor: null,
}),
).toEqual({
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
});
});
describe("getScreenCaptureCursorSetting", () => {
it("normalizes only supported screen-capture cursor settings", () => {
expect(getScreenCaptureCursorSetting({ cursor: "motion" } as MediaTrackSettings)).toBe(
"motion",
);
expect(
getScreenCaptureCursorSetting({ cursor: "hidden" } as MediaTrackSettings),
).toBeNull();
});
});
describe("shouldUseNativeWindowsCaptureForSource", () => {
it("keeps native Windows capture on screen sources", () => {
expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true);
});
it("routes window sources through browser capture", () => {
expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(false);
});
});
function stopRecording(
+192 -63
View File
@@ -29,10 +29,9 @@ const BITS_PER_MEGABIT = 1_000_000;
const MIN_FRAME_RATE = 30;
const CHROME_MEDIA_SOURCE = "desktop";
const RECORDING_FILE_PREFIX = "recording-";
const VIDEO_FILE_EXTENSION = ".webm";
const AUDIO_BITRATE_VOICE = 128_000;
const AUDIO_BITRATE_SYSTEM = 192_000;
const MIC_GAIN_BOOST = 1;
const MIC_GAIN_BOOST = 1.4;
const WEBCAM_BITRATE = 8_000_000;
const WEBCAM_WIDTH = 1280;
const WEBCAM_HEIGHT = 720;
@@ -47,12 +46,14 @@ export type BrowserMicrophoneProfile =
| "no-noise-suppression"
| "raw";
type BrowserCaptureCursorMode = "always" | "never";
type BrowserCaptureCursorSetting = BrowserCaptureCursorMode | "motion";
export type BrowserCaptureCursorPolicy = {
streamCursor: BrowserCaptureCursorMode;
hideOsCursorBeforeRecording: boolean;
hideEditorOverlayCursorByDefault: boolean;
nativeCaptureUnavailable: boolean;
};
const DEFAULT_BROWSER_MICROPHONE_PROFILE: BrowserMicrophoneProfile = "no-agc";
const DEFAULT_BROWSER_MICROPHONE_PROFILE: BrowserMicrophoneProfile = "processed";
const BROWSER_MICROPHONE_PROFILES = new Set<BrowserMicrophoneProfile>([
"processed",
"no-agc",
@@ -191,8 +192,10 @@ export function normalizeBrowserMicrophoneProfile(value?: string | null): Browse
export function resolveBrowserCaptureCursorPolicy({
nativeWindowsCaptureStartFailed = false,
platform,
}: {
nativeWindowsCaptureStartFailed?: boolean;
platform?: string;
} = {}): BrowserCaptureCursorPolicy {
if (nativeWindowsCaptureStartFailed) {
// If WGC already failed, avoid the telemetry overlay path that can lag on
@@ -201,6 +204,19 @@ export function resolveBrowserCaptureCursorPolicy({
streamCursor: "always",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
};
}
if (platform === "linux") {
// Linux screen capture runs through xdg-desktop-portal/PipeWire. Ask the
// portal to omit the cursor, but do not pretend we can globally hide the
// OS cursor from Electron when the portal/compositor ignores that request.
return {
streamCursor: "never",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
};
}
@@ -208,9 +224,46 @@ export function resolveBrowserCaptureCursorPolicy({
streamCursor: "never",
hideOsCursorBeforeRecording: true,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: false,
};
}
export function getScreenCaptureCursorSetting(
settings: MediaTrackSettings | null | undefined,
): BrowserCaptureCursorSetting | null {
const cursor = (settings as { cursor?: unknown } | null | undefined)?.cursor;
return cursor === "always" || cursor === "never" || cursor === "motion" ? cursor : null;
}
export function resolveLinuxPortalCursorPresentation({
actualCursor,
requestedCursor,
}: {
actualCursor: BrowserCaptureCursorSetting | null;
requestedCursor: BrowserCaptureCursorMode;
}): Pick<
BrowserCaptureCursorPolicy,
"hideEditorOverlayCursorByDefault" | "nativeCaptureUnavailable"
> {
if (requestedCursor === "never" && actualCursor === "never") {
return {
hideEditorOverlayCursorByDefault: false,
nativeCaptureUnavailable: false,
};
}
return {
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
};
}
export function shouldUseNativeWindowsCaptureForSource(
source: Pick<ProcessedDesktopSource, "id"> | null | undefined,
): boolean {
return source?.id?.startsWith("screen:") === true;
}
export function createProcessedMicrophoneConstraints(
microphoneDeviceId?: string,
profile: BrowserMicrophoneProfile = DEFAULT_BROWSER_MICROPHONE_PROFILE,
@@ -232,6 +285,31 @@ export function createProcessedMicrophoneConstraints(
return { audio, video: false };
}
export function createBrowserRecordingOptions({
audioBitsPerSecond,
mimeType,
videoBitsPerSecond,
}: {
audioBitsPerSecond?: number;
mimeType?: string;
videoBitsPerSecond: number;
}): MediaRecorderOptions {
const options: MediaRecorderOptions = {
videoBitsPerSecond,
bitsPerSecond: videoBitsPerSecond + (audioBitsPerSecond ?? 0),
};
if (audioBitsPerSecond !== undefined) {
options.audioBitsPerSecond = audioBitsPerSecond;
}
if (mimeType) {
options.mimeType = mimeType;
}
return options;
}
function createMicrophoneTrackSettingsSnapshot(
stream: MediaStream,
): MicrophoneTrackSettingsSnapshot | null {
@@ -342,6 +420,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
const requestedBrowserMicrophoneProfile = useRef<string | null>(null);
const hideEditorOverlayCursorByDefault = useRef(false);
const nativeCaptureUnavailableForCursorOverlay = useRef(false);
const notifyRecordingFinalizationFailure = useCallback(async (message: string) => {
setFinalizing(false);
@@ -650,6 +729,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const start = performance.now();
console.log("[PERF:RENDERER] Finalize Session & Switch to Editor: STARTED");
const shouldHideOverlayCursor = hideEditorOverlayCursorByDefault.current;
const nativeCaptureUnavailable = nativeCaptureUnavailableForCursorOverlay.current;
try {
if (webcamPath) {
await window.electronAPI.setCurrentRecordingSession({
@@ -657,10 +737,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamPath,
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault: shouldHideOverlayCursor,
nativeCaptureUnavailable,
});
} else {
await window.electronAPI.setCurrentVideoPath(videoPath, {
hideOverlayCursorByDefault: shouldHideOverlayCursor,
nativeCaptureUnavailable,
});
}
} catch (error) {
@@ -669,6 +751,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
await window.electronAPI.setCurrentVideoPath(videoPath, {
hideOverlayCursorByDefault: shouldHideOverlayCursor,
nativeCaptureUnavailable,
});
} catch (fallbackError) {
console.error("Failed to persist fallback video path:", fallbackError);
@@ -895,7 +978,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const webcamPath = await stopWebcamRecorder();
await storeMicrophoneSidecar(resolvedMicFallbackBlobPromise, result.path, startDelayMs);
await finalizeRecordingSession(result.path, webcamPath);
if (typeof window.electronAPI?.hudOverlayClose === "function") {
window.electronAPI.hudOverlayClose();
}
@@ -1100,52 +1183,62 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
// We pass null for webcamPath initially to avoid blocking on webcam disk writes/muxing.
await finalizeRecordingSession(finalPath, null);
// 2. Perform background finalization (webcam, muxing, sidecars)
// We don't await this to keep the UI responsive
void (async () => {
try {
// Await the webcam path in the background
const webcamPath = await webcamPathPromise;
console.log("[useScreenRecorder] Background native processing: webcamPath is", webcamPath);
// 2. Perform background finalization (webcam, muxing, sidecars)
// We don't await this to keep the UI responsive
void (async () => {
try {
// Await the webcam path in the background
const webcamPath = await webcamPathPromise;
console.log(
"[useScreenRecorder] Background native processing: webcamPath is",
webcamPath,
);
// Store sidecars
await storeMicrophoneSidecar(
micFallbackBlobPromise,
finalPath,
fallbackStartDelayMs,
fallbackTrackSettings,
);
// Store sidecars
await storeMicrophoneSidecar(
micFallbackBlobPromise,
finalPath,
fallbackStartDelayMs,
fallbackTrackSettings,
);
// Perform muxing/renaming if on Windows
if (isNativeWindows) {
await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs);
}
// Perform muxing/renaming if on Windows
if (isNativeWindows) {
await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs);
}
console.log("[useScreenRecorder] Emitting setCurrentRecordingSession with:", { finalPath, webcamPath });
console.log(
"[useScreenRecorder] Emitting setCurrentRecordingSession with:",
{ finalPath, webcamPath },
);
// Update the session state to notify the editor that all background assets (webcam, mic, etc.) are now ready.
// This broadcasts a 'recording-session-changed' event that the open editor listens to for re-scanning assets.
await window.electronAPI.setCurrentRecordingSession({
videoPath: finalPath,
webcamPath,
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current,
});
// Update the session state to notify the editor that all background assets (webcam, mic, etc.) are now ready.
// This broadcasts a 'recording-session-changed' event that the open editor listens to for re-scanning assets.
await window.electronAPI.setCurrentRecordingSession({
videoPath: finalPath,
webcamPath,
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current,
nativeCaptureUnavailable:
nativeCaptureUnavailableForCursorOverlay.current,
});
console.log(
`[PERF:RENDERER] Background Stop Sequence: COMPLETED in ${(performance.now() - stopStart).toFixed(2)}ms`,
);
} catch (bgError) {
console.error("Error in background finalization:", bgError);
} finally {
// After all background tasks are done (webcam, mic sidecars, muxing),
// we can safely close the HUD window to release hardware and resources.
if (typeof window.electronAPI?.hudOverlayClose === "function") {
console.log("[useScreenRecorder] All background tasks finished, closing HUD");
window.electronAPI.hudOverlayClose();
}
}
})();
console.log(
`[PERF:RENDERER] Background Stop Sequence: COMPLETED in ${(performance.now() - stopStart).toFixed(2)}ms`,
);
} catch (bgError) {
console.error("Error in background finalization:", bgError);
} finally {
// After all background tasks are done (webcam, mic sidecars, muxing),
// we can safely close the HUD window to release hardware and resources.
if (typeof window.electronAPI?.hudOverlayClose === "function") {
console.log(
"[useScreenRecorder] All background tasks finished, closing HUD",
);
window.electronAPI.hudOverlayClose();
}
}
})();
})();
return;
}
@@ -1326,6 +1419,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const platform = await window.electronAPI.getPlatform();
hideEditorOverlayCursorByDefault.current = false;
nativeCaptureUnavailableForCursorOverlay.current = false;
const existingSource = await window.electronAPI.getSelectedSource();
const selectedSource =
existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null);
@@ -1362,8 +1456,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
let nativeWindowsCaptureStartFailed = false;
if (
platform === "win32" &&
(selectedSource.id?.startsWith("screen:") ||
selectedSource.id?.startsWith("window:")) &&
shouldUseNativeWindowsCaptureForSource(selectedSource) &&
typeof window.electronAPI.isNativeWindowsCaptureAvailable === "function"
) {
try {
@@ -1526,9 +1619,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const browserCursorPolicy = resolveBrowserCaptureCursorPolicy({
nativeWindowsCaptureStartFailed,
platform,
});
hideEditorOverlayCursorByDefault.current =
browserCursorPolicy.hideEditorOverlayCursorByDefault;
nativeCaptureUnavailableForCursorOverlay.current =
browserCursorPolicy.nativeCaptureUnavailable;
const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;
const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource);
@@ -1546,7 +1642,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const hideCursorResult = await window.electronAPI.hideOsCursor?.();
if (hideCursorResult && !hideCursorResult.success) {
console.warn("Could not hide OS cursor before recording.", hideCursorResult);
console.warn(
"Could not hide OS cursor before recording.",
hideCursorResult,
);
}
} catch {
console.warn("Could not hide OS cursor before recording.");
@@ -1709,6 +1808,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
throw new Error("Media stream is not available.");
}
if (useLinuxPortal) {
const actualCursor = getScreenCaptureCursorSetting(videoTrack.getSettings());
const cursorPresentation = resolveLinuxPortalCursorPresentation({
actualCursor,
requestedCursor: browserCursorPolicy.streamCursor,
});
hideEditorOverlayCursorByDefault.current =
cursorPresentation.hideEditorOverlayCursorByDefault;
nativeCaptureUnavailableForCursorOverlay.current =
cursorPresentation.nativeCaptureUnavailable;
if (cursorPresentation.nativeCaptureUnavailable) {
console.warn(
"Linux portal did not confirm cursor-hidden capture; disabling Recordly cursor overlay for this recording.",
{
actualCursor,
requestedCursor: browserCursorPolicy.streamCursor,
},
);
}
}
try {
await videoTrack.applyConstraints({
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
@@ -1742,17 +1862,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
chunks.current = [];
const hasAudio = stream.current.getAudioTracks().length > 0;
const recorder = new MediaRecorder(stream.current, {
videoBitsPerSecond,
...(mimeType ? { mimeType } : {}),
...(hasAudio
? {
audioBitsPerSecond: systemAudioIncluded
? AUDIO_BITRATE_SYSTEM
: AUDIO_BITRATE_VOICE,
}
: {}),
});
const audioBitsPerSecond = hasAudio
? systemAudioIncluded
? AUDIO_BITRATE_SYSTEM
: AUDIO_BITRATE_VOICE
: undefined;
const recorder = new MediaRecorder(
stream.current,
createBrowserRecordingOptions({
audioBitsPerSecond,
mimeType,
videoBitsPerSecond,
}),
);
mediaRecorder.current = recorder;
recorder.ondataavailable = (event) => {
@@ -1774,10 +1896,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
chunks.current = [];
const timestamp = recordingSessionTimestamp.current ?? Date.now();
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${getVideoExtensionForMimeType(recordingBlobType)}`;
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
const videoBlob = isWebmMimeType(recordingBlobType)
? await fixWebmDuration(buggyBlob, duration)
: buggyBlob;
const arrayBuffer = await videoBlob.arrayBuffer();
const videoResult = await window.electronAPI.storeRecordedVideo(
arrayBuffer,
@@ -1808,14 +1932,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
videoPath: finalVideoPath,
webcamPath,
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current,
hideOverlayCursorByDefault:
hideEditorOverlayCursorByDefault.current,
nativeCaptureUnavailable:
nativeCaptureUnavailableForCursorOverlay.current,
});
}
} finally {
// After all background tasks are done (webcam),
// we can safely close the HUD window to release hardware and resources.
if (typeof window.electronAPI?.hudOverlayClose === "function") {
console.log("[useScreenRecorder:browser] All background tasks finished, closing HUD");
console.log(
"[useScreenRecorder:browser] All background tasks finished, closing HUD",
);
window.electronAPI.hudOverlayClose();
}
}
+5
View File
@@ -113,6 +113,11 @@
"project": {
"untitled": "Sans titre"
},
"nativeCaptureUnavailable": {
"title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.",
"description": "Votre appareil ne prend pas en charge la capture native. Cela peut arriver pour plusieurs raisons que nous n'avons pas encore identifiées. Recordly continuera de fonctionner, mais le lissage du curseur sera impossible.",
"confirm": "D'accord"
},
"exportStatus": {
"exporting": "Exportation",
"renderingFile": "Rendu de votre fichier.",
+5
View File
@@ -114,6 +114,11 @@
"project": {
"untitled": "제목 없음"
},
"nativeCaptureUnavailable": {
"title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.",
"description": "이 장치는 네이티브 캡처를 지원하지 않습니다. 아직 확인하지 못한 여러 이유가 있을 수 있습니다. Recordly는 계속 작동하지만 커서 스무딩은 사용할 수 없습니다.",
"confirm": "확인"
},
"exportStatus": {
"exporting": "내보내는 중",
"renderingFile": "파일을 렌더링하고 있습니다.",
+5
View File
@@ -114,6 +114,11 @@
"project": {
"untitled": "Naamloos"
},
"nativeCaptureUnavailable": {
"title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.",
"description": "Je apparaat ondersteunt geen native capture. Dit kan verschillende oorzaken hebben die we nog niet hebben achterhaald. Recordly blijft werken, maar cursor smoothing is dan niet mogelijk.",
"confirm": "Oké"
},
"exportStatus": {
"exporting": "Exporteren",
"renderingFile": "Je bestand wordt gerenderd.",
+5
View File
@@ -113,6 +113,11 @@
"project": {
"untitled": "Sem título"
},
"nativeCaptureUnavailable": {
"title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.",
"description": "Seu dispositivo não oferece suporte à captura nativa. Isso pode acontecer por vários motivos que ainda não identificamos. O Recordly continuará funcionando, mas a suavização do cursor ficará indisponível.",
"confirm": "Entendi"
},
"exportStatus": {
"exporting": "Exportando",
"renderingFile": "Renderizando seu arquivo.",
+5
View File
@@ -113,6 +113,11 @@
"project": {
"untitled": "未命名"
},
"nativeCaptureUnavailable": {
"title": "没有出错,但我们无法渲染动画光标叠加层。",
"description": "你的设备不支持原生捕获。这可能是由我们尚未确定的多种原因造成的。Recordly 仍可继续运行,但无法进行光标平滑处理。",
"confirm": "好的"
},
"exportStatus": {
"exporting": "正在导出",
"renderingFile": "正在渲染你的文件。",
+6 -1
View File
@@ -113,6 +113,11 @@
"project": {
"untitled": "未命名"
},
"nativeCaptureUnavailable": {
"title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。",
"description": "你的裝置不支援原生擷取。這可能是由我們尚未釐清的多種原因造成的。Recordly 仍可繼續運作,但無法進行游標平滑處理。",
"confirm": "好的"
},
"exportStatus": {
"exporting": "正在匯出",
"renderingFile": "正在渲染你的檔案。",
@@ -134,4 +139,4 @@
"collapse": "摺疊時間軸"
},
"openRecordingsFolder": "打開錄製資料夾"
}
}
+45
View File
@@ -34,6 +34,28 @@ describe("export bitrate policy", () => {
expect(bitrate60).toBeGreaterThan(bitrate30);
});
it("raises high-resolution 60fps source-quality exports above the 30fps budget", () => {
const sharedOptions = {
width: 2560,
height: 1440,
quality: "source" as const,
encodingMode: "quality" as const,
};
const thirtyFpsBitrate = getMp4ExportBitrate({
...sharedOptions,
frameRate: 30,
});
const sixtyFpsBitrate = getMp4ExportBitrate({
...sharedOptions,
frameRate: 60,
});
expect(thirtyFpsBitrate).toBe(45_000_000);
expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate);
expect(sixtyFpsBitrate).toBe(63_639_610);
});
it("keeps modern native static-layout source exports high enough for screen text", () => {
expect(
getMp4ExportBitrate({
@@ -57,6 +79,29 @@ describe("export bitrate policy", () => {
).toBe(27_000_000);
});
it("scales modern native static-layout source exports at 60fps", () => {
const sharedOptions = {
width: 1920,
height: 1080,
quality: "source" as const,
encodingMode: "quality" as const,
useModernNativeStaticLayout: true,
};
const thirtyFpsBitrate = getMp4ExportBitrate({
...sharedOptions,
frameRate: 30,
});
const sixtyFpsBitrate = getMp4ExportBitrate({
...sharedOptions,
frameRate: 60,
});
expect(thirtyFpsBitrate).toBe(27_000_000);
expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate);
expect(sixtyFpsBitrate).toBe(38_183_766);
});
it("does not raise fast exports when the requested bitrate is already lower than the cap", () => {
expect(
getMp4ExportBitrate({
+8 -4
View File
@@ -42,8 +42,12 @@ function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQ
return 30_000_000;
}
function getFrameRateBitrateScale(frameRate: ExportMp4FrameRate): number {
return Math.sqrt(Math.max(frameRate, REFERENCE_FRAME_RATE) / REFERENCE_FRAME_RATE);
function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number {
// This only scales requestedBitrate above REFERENCE_FRAME_RATE, so 24fps
// and 30fps share the same multiplier. useModernNativeStaticLayout can
// still change the final bitrate because pixelRateScale uses frameRate
// against REFERENCE_PIXEL_RATE for the native layout floor/cap.
return Math.sqrt(Math.max(1, frameRate / REFERENCE_FRAME_RATE));
}
function getModernNativeStaticLayoutBitrateCap(
@@ -92,8 +96,8 @@ export function getMp4ExportBitrate(options: {
}): number {
const requestedBitrate = Math.round(
getBaseMp4ExportBitrate(options.width, options.height, options.quality) *
getEncodingModeBitrateMultiplier(options.encodingMode) *
getFrameRateBitrateScale(options.frameRate),
getFrameRateBitrateMultiplier(options.frameRate) *
getEncodingModeBitrateMultiplier(options.encodingMode),
);
const nativeStaticLayoutBitrate =
options.useModernNativeStaticLayout && options.encodingMode !== "fast"
+1 -1
View File
@@ -37,7 +37,7 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
display: "Del / ⌫",
bindings: [{ key: "delete" }, { key: "backspace" }],
},
{ label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
{ label: "Pan Timeline", display: "Shift + Scroll", bindings: [] },
{ label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
];