diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts index 04bc577c..77c8dd35 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -190,7 +190,17 @@ export async function ensureSwiftHelperBinary( const prebundledPath = getPrebundledNativeHelperPath(prebundledBinaryName); try { await fs.access(prebundledPath, fsConstants.X_OK); - return prebundledPath; + if (app.isPackaged) { + return prebundledPath; + } + + const [sourceStat, prebundledStat] = await Promise.all([ + fs.stat(sourcePath), + fs.stat(prebundledPath), + ]); + if (prebundledStat.mtimeMs >= sourceStat.mtimeMs) { + return prebundledPath; + } } catch { if (app.isPackaged) { throw new Error( diff --git a/electron/ipc/project/session.ts b/electron/ipc/project/session.ts index d5f83a83..7b2131ae 100644 --- a/electron/ipc/project/session.ts +++ b/electron/ipc/project/session.ts @@ -62,7 +62,6 @@ export async function resolveRecordingSessionManifest( typeof parsed.webcamFileName === "string" && parsed.webcamFileName.trim() ? parsed.webcamFileName.trim() : null; - if (!webcamFileName) { return { videoPath: normalizedVideoPath, diff --git a/electron/ipc/recording/windows.test.ts b/electron/ipc/recording/windows.test.ts index 73a1e0c2..5d691cb8 100644 --- a/electron/ipc/recording/windows.test.ts +++ b/electron/ipc/recording/windows.test.ts @@ -1,9 +1,16 @@ import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; import { PassThrough } from "node:stream"; +import { fileURLToPath } from "node:url"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setWindowsCaptureOutputBuffer, setWindowsCaptureTargetPath } from "../state"; import { waitForWindowsCaptureStop } from "./windows"; +const windowsCaptureSource = readFileSync( + fileURLToPath(new URL("../../native/wgc-capture/src/wgc_session.cpp", import.meta.url)), + "utf8", +); + vi.mock("electron", () => ({ app: { getPath: () => "C:\\RecordlyTest", @@ -100,3 +107,20 @@ describe("waitForWindowsCaptureStop", () => { expect(proc.kill).toHaveBeenCalledTimes(1); }); }); + +describe("native Windows window capture", () => { + it("crops monitor frames to the selected window bounds", () => { + expect(windowsCaptureSource).not.toContain("CreateForWindow("); + expect(windowsCaptureSource).toContain("DwmGetWindowAttribute("); + expect(windowsCaptureSource).toContain("CopySubresourceRegion(cropTexture_"); + }); + + it("resizes the crop texture when the selected window size changes", () => { + expect(windowsCaptureSource).toContain( + "nextWidth != captureWidth_ || nextHeight != captureHeight_", + ); + expect(windowsCaptureSource).toContain( + "d3dDevice_->CreateTexture2D(&desc, nullptr, &resizedTexture)", + ); + }); +}); diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index a3a0cd07..ff309a19 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -16,9 +16,9 @@ import { loadProjectFromPath, loadRecentProjectPaths, persistRecordingsDirectorySetting, + rememberApprovedLocalReadPath, rememberRecentProject, replaceApprovedSessionLocalReadPaths, - rememberApprovedLocalReadPath, resolveApprovedLocalMediaPath, saveProjectThumbnail, saveRecentProjectPaths, diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index a1b62504..05373691 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -155,6 +155,7 @@ import { parseWindowId, } from "../utils"; import { resolveWindowsCaptureTarget } from "../windowsCaptureSelection"; +import { bringSelectedWindowForward } from "./sources"; const execFileAsync = promisify(execFile); @@ -406,6 +407,9 @@ export function registerRecordingHandlers( // Capture starts before the renderer publishes its recording-state // transition, so protect the HUD at the actual capture boundary. reassertHudOverlayCaptureProtection(); + const visibleWindowBounds = source.id?.startsWith("window:") + ? await bringSelectedWindowForward(source) + : null; // Windows native capture path if (process.platform === "win32") { @@ -765,6 +769,12 @@ export function registerRecordingHandlers( if (Number.isFinite(windowId) && windowId && source?.id?.startsWith("window:")) { config.windowId = windowId; + if (visibleWindowBounds) { + config.windowX = visibleWindowBounds.x; + config.windowY = visibleWindowBounds.y; + config.windowWidth = visibleWindowBounds.width; + config.windowHeight = visibleWindowBounds.height; + } } else if (Number.isFinite(screenId) && screenId > 0) { config.displayId = screenId; } else { @@ -816,7 +826,10 @@ export function registerRecordingHandlers( microphonePath: nativeCaptureMicrophonePath, processOutput: nativeCaptureOutputBuffer.trim() || undefined, }); - return { success: true, microphoneFallbackRequired: micUnavailableNatively }; + return { + success: true, + microphoneFallbackRequired: micUnavailableNatively, + }; } catch (error) { console.error("Failed to start native ScreenCaptureKit recording:", error); const errorStr = String(error); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index a92e5580..b4cf9f1c 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -1,20 +1,20 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { app, BrowserWindow, desktopCapturer, ipcMain } from "electron"; +import { app, BrowserWindow, desktopCapturer, ipcMain, systemPreferences } from "electron"; +import { reassertHudOverlayMousePassthrough } from "../../windows"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; -import { selectedSource, setSelectedSource } from "../state"; -import type { SelectedSource } from "../types"; -import { getScreen, parseWindowId } from "../utils"; -import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg"; -import { getScreenSourceIdForDisplay } from "./sourceMapping"; import { getNativeMacWindowSources, + resolveLinuxWindowBounds, resolveMacWindowBounds, resolveWindowsWindowBounds, - resolveLinuxWindowBounds, stopWindowBoundsCapture, } from "../cursor/bounds"; -import { reassertHudOverlayMousePassthrough } from "../../windows"; +import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg"; +import { selectedSource, setSelectedSource } from "../state"; +import type { SelectedSource, WindowBounds } from "../types"; +import { getScreen, parseWindowId } from "../utils"; +import { getScreenSourceIdForDisplay } from "./sourceMapping"; const execFileAsync = promisify(execFile); const SOURCE_LIST_CACHE_TTL_MS = 1200; @@ -36,6 +36,96 @@ function broadcastSelectedSourceChange() { } } +export async function bringSelectedWindowForward( + source: SelectedSource, +): Promise { + const windowId = parseWindowId(source.id); + if (!windowId) return null; + + try { + if (process.platform === "darwin") { + const rawAppName = source.appName || source.name?.split(" — ")[0]?.trim(); + const appName = + rawAppName && /^[\w .&()+'-]{1,64}$/.test(rawAppName) ? rawAppName : null; + if (!appName) return null; + await execFileAsync("open", ["-a", appName], { timeout: 2000 }); + try { + systemPreferences?.isTrustedAccessibilityClient?.(true); + const { stdout } = await execFileAsync( + "osascript", + [ + "-e", + "on run argv", + "-e", + 'tell application "System Events" to tell process (item 1 of argv)', + "-e", + "repeat with candidate in windows", + "-e", + "try", + "-e", + 'if value of attribute "AXWindowNumber" of candidate is (item 2 of argv) as integer then', + "-e", + 'perform action "AXRaise" of candidate', + "-e", + "set windowPosition to position of candidate", + "-e", + "set windowSize to size of candidate", + "-e", + 'return ((item 1 of windowPosition) as text) & "," & ((item 2 of windowPosition) as text) & "," & ((item 1 of windowSize) as text) & "," & ((item 2 of windowSize) as text)', + "-e", + "end if", + "-e", + "end try", + "-e", + "end repeat", + "-e", + "end tell", + "-e", + "end run", + "--", + appName, + String(windowId), + ], + { timeout: 2000 }, + ); + const [x, y, width, height] = stdout.trim().split(",").map(Number); + if ([x, y, width, height].every(Number.isFinite) && width > 0 && height > 0) { + await new Promise((resolve) => setTimeout(resolve, 250)); + return { x, y, width, height }; + } + } catch { + // App activation still works without macOS Accessibility permission. + } + } else if (process.platform === "win32") { + const script = [ + 'Add-Type -TypeDefinition @"', + "using System; using System.Runtime.InteropServices;", + "public static class RecordlyForegroundWindow {", + ' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);', + ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);', + "}", + '"@', + "$handle = [IntPtr][Int64]$args[0]", + "[RecordlyForegroundWindow]::ShowWindowAsync($handle, 9) | Out-Null", + "[RecordlyForegroundWindow]::SetForegroundWindow($handle) | Out-Null", + ].join("\n"); + await execFileAsync( + "powershell.exe", + ["-NoProfile", "-Command", script, String(windowId)], + { timeout: 2000 }, + ); + } else if (process.platform === "linux") { + await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], { + timeout: 1500, + }); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } catch { + // Raising the source is best-effort; selection and capture can still continue. + } + return null; +} + export function registerSourceHandlers({ createEditorWindow, createSourceSelectorWindow, @@ -246,6 +336,7 @@ export function registerSourceHandlers({ : null, appName: source.appName, windowTitle: source.windowTitle, + bundleId: source.bundleId, sourceType: "window" as const, }; }); @@ -306,7 +397,10 @@ export function registerSourceHandlers({ } }); - ipcMain.handle("select-source", (_, source: SelectedSource) => { + ipcMain.handle("select-source", async (_, source: SelectedSource) => { + if (source.id?.startsWith("window:")) { + await bringSelectedWindowForward(source); + } setSelectedSource(source); broadcastSelectedSourceChange(); stopWindowBoundsCapture(); @@ -314,58 +408,15 @@ export function registerSourceHandlers({ if (sourceSelectorWin) { sourceSelectorWin.close(); } + app.focus({ steal: true }); return selectedSource; }); ipcMain.handle("show-source-highlight", async (_, source: SelectedSource) => { try { const isWindow = source.id?.startsWith("window:"); - const windowId = isWindow ? parseWindowId(source.id) : null; - // ── 1. Bring window to front ── - if (isWindow && process.platform === "darwin") { - const rawAppName = source.appName || source.name?.split(" — ")[0]?.trim(); - const appName = - rawAppName && /^[\w .&()+'-]{1,64}$/.test(rawAppName) ? rawAppName : null; - if (appName) { - try { - await execFileAsync( - "osascript", - [ - "-e", - "on run argv", - "-e", - "tell application (item 1 of argv) to activate", - "-e", - "end run", - "--", - appName, - ], - { timeout: 2000 }, - ); - await new Promise((resolve) => setTimeout(resolve, 350)); - } catch { - /* ignore */ - } - } - } else if (windowId && process.platform === "linux") { - try { - await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], { - timeout: 1500, - }); - } catch { - try { - await execFileAsync("xdotool", ["windowactivate", String(windowId)], { - timeout: 1500, - }); - } catch { - /* not available */ - } - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - // ── 2. Resolve bounds ── + // ── 1. Resolve bounds ── let bounds: { x: number; y: number; width: number; height: number } | null = null; if (source.id?.startsWith("screen:")) { @@ -397,7 +448,7 @@ export function registerSourceHandlers({ const resolvedBounds = bounds; - // ── 3. Show traveling wave highlight ── + // ── 2. Show traveling wave highlight ── // On macOS, screen highlights use workArea and no outward padding — // macOS clamps window positions below the menu bar so outward // padding only works on the left/top while right/bottom run off-screen. @@ -416,10 +467,19 @@ export function registerSourceHandlers({ hasShadow: false, resizable: false, focusable: false, + show: false, + ...(process.platform === "darwin" ? { type: "panel" as const } : {}), webPreferences: { nodeIntegration: false, contextIsolation: true }, }); highlightWin.setIgnoreMouseEvents(true); + highlightWin.setAlwaysOnTop(true, "screen-saver"); + if (process.platform === "darwin") { + highlightWin.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }); + } const borderRadius = isMacScreen ? 0 : 10; const glowInset = isMacScreen ? 0 : -4; @@ -436,11 +496,11 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} background:conic-gradient(from var(--angle,0deg), transparent 0%, transparent 60%, - rgba(99,96,245,.15) 70%, - rgba(99,96,245,.9) 80%, - rgba(123,120,255,1) 85%, - rgba(99,96,245,.9) 90%, - rgba(99,96,245,.15) 95%, + rgba(37,99,235,.15) 70%, + rgba(37,99,235,.9) 80%, + rgba(117,166,255,1) 85%, + rgba(37,99,235,.9) 90%, + rgba(37,99,235,.15) 95%, transparent 100% ); -webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0); @@ -454,9 +514,9 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} background:conic-gradient(from var(--angle,0deg), transparent 0%, transparent 65%, - rgba(99,96,245,.3) 78%, - rgba(123,120,255,.5) 85%, - rgba(99,96,245,.3) 92%, + rgba(37,99,235,.3) 78%, + rgba(117,166,255,.5) 85%, + rgba(37,99,235,.3) 92%, transparent 100% ); -webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0); @@ -490,6 +550,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} await highlightWin.loadURL( `data:text/html;charset=utf-8,${encodeURIComponent(html)}`, ); + highlightWin.showInactive(); } catch (loadError) { if (!highlightWin.isDestroyed()) { highlightWin.close(); diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index ac277aec..99980f86 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -2,11 +2,16 @@ import Foundation import ScreenCaptureKit import AVFoundation import CoreGraphics +import CoreImage struct CaptureConfig: Codable { let fps: Int? let displayId: CGDirectDisplayID? let windowId: UInt32? + let windowX: Double? + let windowY: Double? + let windowWidth: Double? + let windowHeight: Double? let outputPath: String? let capturesSystemAudio: Bool? let capturesMicrophone: Bool? @@ -33,6 +38,12 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private let queue = DispatchQueue(label: "recordly.screencapturekit.video") private var assetWriter: AVAssetWriter? private var videoInput: AVAssetWriterInput? + private var videoPixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor? + private var windowCropRect: CGRect? + private var windowCropDisplayId: CGDirectDisplayID? + private var excludedProcessIds = Set() + private var lastCroppedPixelBuffer: CVPixelBuffer? + private let imageContext = CIContext(options: [.cacheIntermediates: false]) private var systemAudioWriter: AVAssetWriter? private var systemAudioInput: AVAssetWriterInput? private var microphoneOnlyWriter: AVAssetWriter? @@ -114,6 +125,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let filter: SCContentFilter let outputWidth: Int let outputHeight: Int + excludedProcessIds = Set(config.excludedProcessIds ?? []) + let excludedApplications = availableContent.applications.filter { + excludedProcessIds.contains($0.processID) + } if let windowId = config.windowId { trackedWindowId = windowId @@ -121,30 +136,49 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"]) } - filter = SCContentFilter(desktopIndependentWindow: window) - - let candidateDisplay = availableContent.displays.first(where: { - $0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY)) - }) - let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID()) - outputWidth = max(2, Int(window.frame.width) * scaleFactor) - outputHeight = max(2, Int(window.frame.height) * scaleFactor) - if #available(macOS 14.0, *) { - streamConfig.ignoreShadowsSingleWindow = true + // Accessibility reports the visible frame at the native border. + let visibleFrame: CGRect + if let x = config.windowX, + let y = config.windowY, + let width = config.windowWidth, + let height = config.windowHeight, + width > 0, + height > 0 { + visibleFrame = CGRect(x: x, y: y, width: width, height: height) + } else { + visibleFrame = window.frame } - streamConfig.width = outputWidth - streamConfig.height = outputHeight + guard let display = Self.captureDisplay(for: visibleFrame, from: availableContent.displays) else { + throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Window display not found"]) + } + let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) + let captureRect = visibleFrame.intersection(display.frame) + filter = SCContentFilter( + display: display, + excludingApplications: excludedApplications, + exceptingWindows: [] + ) + windowCropRect = CGRect( + x: (captureRect.minX - display.frame.minX) / display.frame.width, + y: (captureRect.minY - display.frame.minY) / display.frame.height, + width: captureRect.width / display.frame.width, + height: captureRect.height / display.frame.height + ) + windowCropDisplayId = display.displayID + outputWidth = max(2, Int(captureRect.width) * scaleFactor) & ~1 + outputHeight = max(2, Int(captureRect.height) * scaleFactor) & ~1 + streamConfig.width = max(2, Int(display.frame.width) * scaleFactor) + streamConfig.height = max(2, Int(display.frame.height) * scaleFactor) + streamConfig.pixelFormat = kCVPixelFormatType_32BGRA } else { trackedWindowId = nil + windowCropRect = nil + windowCropDisplayId = nil let displayId = config.displayId ?? CGMainDisplayID() guard let display = availableContent.displays.first(where: { $0.displayID == displayId }) else { throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"]) } - let excludedProcessIds = Set(config.excludedProcessIds ?? []) - let excludedApplications = availableContent.applications.filter { - excludedProcessIds.contains($0.processID) - } filter = SCContentFilter( display: display, excludingApplications: excludedApplications, @@ -181,7 +215,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let sourceVideoFormat = try CMVideoFormatDescription( videoCodecType: CMFormatDescription.MediaSubType( - rawValue: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + rawValue: windowCropRect == nil + ? kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + : kCVPixelFormatType_32BGRA ), width: outputWidth, height: outputHeight @@ -213,6 +249,16 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { assetWriter.add(videoInput) self.videoInput = videoInput + videoPixelBufferAdaptor = windowCropRect.map { _ in + AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: videoInput, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: outputWidth, + kCVPixelBufferHeightKey as String: outputHeight, + ] + ) + } // Add inline audio track directly to the video so the .mp4 always contains audio. // This eliminates the dependency on the post-recording ffmpeg mux step. @@ -372,9 +418,18 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } lastSampleBuffer = sampleBuffer - let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) - if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { - if videoInput.append(retimedSampleBuffer) { + let appended: Bool + if videoPixelBufferAdaptor != nil { + appended = appendCroppedVideoFrame(sampleBuffer, at: presentationTime) + } else { + let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) + if let retimed = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { + appended = videoInput.append(retimed) + } else { + appended = false + } + } + if appended { lastVideoPresentationTime = presentationTime lastVideoDuration = sampleBuffer.duration frameCount += 1 @@ -384,7 +439,6 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { print("Recording started") fflush(stdout) } - } } return } @@ -413,6 +467,48 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return } + private func appendCroppedVideoFrame(_ sampleBuffer: CMSampleBuffer, at presentationTime: CMTime) -> Bool { + guard let crop = windowCropRect, + let adaptor = videoPixelBufferAdaptor, + let pool = adaptor.pixelBufferPool, + let source = CMSampleBufferGetImageBuffer(sampleBuffer) else { return false } + + var destination: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &destination) == kCVReturnSuccess, + let destination else { return false } + + let sourceWidth = CGFloat(CVPixelBufferGetWidth(source)) + let sourceHeight = CGFloat(CVPixelBufferGetHeight(source)) + let sourceRect = CGRect( + x: crop.minX * sourceWidth, + y: (1 - crop.maxY) * sourceHeight, + width: crop.width * sourceWidth, + height: crop.height * sourceHeight + ) + let destinationSize = CGSize( + width: CVPixelBufferGetWidth(destination), + height: CVPixelBufferGetHeight(destination) + ) + let image = CIImage(cvPixelBuffer: source) + .cropped(to: sourceRect) + .transformed(by: CGAffineTransform(translationX: -sourceRect.minX, y: -sourceRect.minY)) + .transformed(by: CGAffineTransform( + scaleX: destinationSize.width / sourceRect.width, + y: destinationSize.height / sourceRect.height + )) + let bounds = CGRect(origin: .zero, size: destinationSize) + imageContext.render( + image, + to: destination, + bounds: bounds, + colorSpace: CGColorSpace(name: CGColorSpace.sRGB) + ) + + let appended = adaptor.append(destination, withPresentationTime: presentationTime) + if appended { lastCroppedPixelBuffer = destination } + return appended + } + func stream(_ stream: SCStream, didStopWithError error: Error) { fputs("Error: \(error.localizedDescription)\n", stderr) fflush(stderr) @@ -497,9 +593,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let videoInput = videoInput, await waitUntilReady(videoInput, of: assetWriter) { let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer) - let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) - if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { + if let adaptor = videoPixelBufferAdaptor, let pixelBuffer = lastCroppedPixelBuffer { + adaptor.append(pixelBuffer, withPresentationTime: additionalTime) + } else { + let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) + if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { videoInput.append(additionalSampleBuffer) + } } } @@ -536,6 +636,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let path = outputURL?.path ?? "" assetWriter = nil videoInput = nil + videoPixelBufferAdaptor = nil + windowCropRect = nil + windowCropDisplayId = nil + excludedProcessIds.removeAll() + lastCroppedPixelBuffer = nil systemAudioWriter = nil systemAudioInput = nil microphoneOnlyWriter = nil @@ -767,8 +872,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { continue } - let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId }) - if !windowStillAvailable { + guard let window = availableContent.windows.first(where: { $0.windowID == trackedWindowId }) else { print("WINDOW_UNAVAILABLE") fflush(stdout) let finalization = await self.finalizeCapture(interactive: false) @@ -785,11 +889,59 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { fflush(stderr) exit(1) } + return + } + + guard let display = Self.captureDisplay(for: window.frame, from: availableContent.displays) else { + continue + } + let captureRect = window.frame.intersection(display.frame) + guard captureRect.width > 0, captureRect.height > 0 else { continue } + let cropRect = CGRect( + x: (captureRect.minX - display.frame.minX) / display.frame.width, + y: (captureRect.minY - display.frame.minY) / display.frame.height, + width: captureRect.width / display.frame.width, + height: captureRect.height / display.frame.height + ) + + if self.windowCropDisplayId != display.displayID, let activeStream = self.stream { + let excludedApplications = availableContent.applications.filter { + self.excludedProcessIds.contains($0.processID) + } + let filter = SCContentFilter( + display: display, + excludingApplications: excludedApplications, + exceptingWindows: [] + ) + do { + try await activeStream.updateContentFilter(filter) + } catch { + continue + } + } + + await withCheckedContinuation { (continuation: CheckedContinuation) in + self.queue.async { + if self.isRecording { + self.windowCropRect = cropRect + self.windowCropDisplayId = display.displayID + } + continuation.resume() + } } } } } + private static func captureDisplay(for frame: CGRect, from displays: [SCDisplay]) -> SCDisplay? { + let midpoint = CGPoint(x: frame.midX, y: frame.midY) + return displays.first(where: { $0.frame.contains(midpoint) }) + ?? displays.filter { $0.frame.intersects(frame) }.max { + $0.frame.intersection(frame).width * $0.frame.intersection(frame).height + < $1.frame.intersection(frame).width * $1.frame.intersection(frame).height + } + } + private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int { guard let mode = CGDisplayCopyDisplayMode(displayId) else { return 1 @@ -876,8 +1028,7 @@ guard CommandLine.arguments.count >= 2 else { } // Force CoreGraphics Services initialization on the main thread. -// Without this, SCContentFilter(desktopIndependentWindow:) crashes with -// CGS_REQUIRE_INIT because CGS is never initialised in a CLI tool. +// ScreenCaptureKit still requires CoreGraphics Services to be initialized in a CLI tool. let _ = CGMainDisplayID() // Pre-flight check: ensure screen recording permission is granted before diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index 0956bf6f..a885d75c 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -50,9 +50,7 @@ describe("ScreenCaptureKitRecorder colour metadata", () => { expect(recorderSource).toContain( "streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2", ); - expect(recorderSource).toContain( - "rawValue: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange", - ); + expect(recorderSource).toContain("kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange"); expect(recorderSource).toContain("sourceFormatHint: sourceVideoFormat"); expect(recorderSource).not.toContain("videoCodecType: .h264"); }); @@ -64,3 +62,25 @@ describe("ScreenCaptureKitRecorder colour metadata", () => { expect(recorderSource).toContain("AVVideoYCbCrMatrix_ITU_R_709_2"); }); }); + +describe("ScreenCaptureKitRecorder window capture", () => { + it("records the display and crops it to the selected window bounds", () => { + expect(recorderSource).not.toContain("streamConfig.sourceRect"); + expect(recorderSource).not.toContain("desktopIndependentWindow"); + expect(recorderSource).toContain( + "visibleFrame = CGRect(x: x, y: y, width: width, height: height)", + ); + expect(recorderSource).toContain( + "let captureRect = visibleFrame.intersection(display.frame)", + ); + expect(recorderSource).toContain("appendCroppedVideoFrame(sampleBuffer"); + }); + + it("refreshes the crop and capture display while the window moves or resizes", () => { + expect(recorderSource).toContain( + "guard let display = Self.captureDisplay(for: window.frame", + ); + expect(recorderSource).toContain("try await activeStream.updateContentFilter(filter)"); + expect(recorderSource).toContain("self.windowCropRect = cropRect"); + }); +}); diff --git a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor index 962161c1..192f8719 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 933de2c2..52c23c0e 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-arm64/recordly-system-cursors b/electron/native/bin/darwin-arm64/recordly-system-cursors index f4b41ab6..a51c8e1a 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-system-cursors and b/electron/native/bin/darwin-arm64/recordly-system-cursors differ diff --git a/electron/native/bin/darwin-arm64/recordly-window-list b/electron/native/bin/darwin-arm64/recordly-window-list index 76a7dab4..d8028716 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-window-list and b/electron/native/bin/darwin-arm64/recordly-window-list differ diff --git a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor index 3e55919f..b375827a 100755 Binary files a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 9217c397..ed9450cc 100755 Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-system-cursors b/electron/native/bin/darwin-x64/recordly-system-cursors index 54561362..d7852055 100755 Binary files a/electron/native/bin/darwin-x64/recordly-system-cursors and b/electron/native/bin/darwin-x64/recordly-system-cursors differ diff --git a/electron/native/bin/darwin-x64/recordly-window-list b/electron/native/bin/darwin-x64/recordly-window-list index e165257a..85d6ef65 100755 Binary files a/electron/native/bin/darwin-x64/recordly-window-list and b/electron/native/bin/darwin-x64/recordly-window-list differ diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 6b6c07ae..15b0859a 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -24,4 +24,5 @@ target_link_libraries(wgc-capture PRIVATE mfuuid ole32 shcore + dwmapi ) diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index 898a9fdc..e9c53f31 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -9,6 +10,7 @@ #include #include +#include // IDirect3DDxgiInterfaceAccess is a COM interface for getting the DXGI interface // from a WinRT IDirect3DSurface @@ -94,26 +96,6 @@ winrt::Windows::Graphics::Capture::GraphicsCaptureItem WgcSession::createCapture return item; } -winrt::Windows::Graphics::Capture::GraphicsCaptureItem WgcSession::createCaptureItemForWindow(HWND hwnd) { - auto factory = winrt::get_activation_factory< - winrt::Windows::Graphics::Capture::GraphicsCaptureItem>(); - - auto interop = factory.as(); - - winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{nullptr}; - HRESULT hr = interop->CreateForWindow( - hwnd, - winrt::guid_of(), - winrt::put_abi(item)); - - if (FAILED(hr)) { - std::cerr << "ERROR: CreateForWindow failed: 0x" << std::hex << hr << std::endl; - return nullptr; - } - - return item; -} - bool WgcSession::initializeWithItem(int fps) { if (!captureItem_) return false; @@ -205,8 +187,61 @@ bool WgcSession::initialize(HWND hwnd, int fps) { return false; } - captureItem_ = createCaptureItemForWindow(hwnd); - return initializeWithItem(fps); + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (!monitor) return false; + + captureItem_ = createCaptureItemForMonitor(monitor); + return initializeWithItem(fps) && initializeWindowCrop(hwnd); +} + +bool WgcSession::initializeWindowCrop(HWND hwnd) { + windowHandle_ = hwnd; + MONITORINFO monitorInfo{}; + monitorInfo.cbSize = sizeof(monitorInfo); + if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &monitorInfo)) return false; + monitorBounds_ = monitorInfo.rcMonitor; + + RECT windowBounds{}; + if (FAILED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &windowBounds, sizeof(windowBounds))) && + !GetWindowRect(hwnd, &windowBounds)) return false; + RECT clipped{}; + if (!IntersectRect(&clipped, &windowBounds, &monitorBounds_)) return false; + return updateWindowCropRect(); +} + +bool WgcSession::updateWindowCropRect() { + RECT windowBounds{}; + if (FAILED(DwmGetWindowAttribute(windowHandle_, DWMWA_EXTENDED_FRAME_BOUNDS, &windowBounds, sizeof(windowBounds))) && + !GetWindowRect(windowHandle_, &windowBounds)) return false; + RECT clipped{}; + if (!IntersectRect(&clipped, &windowBounds, &monitorBounds_)) return false; + const LONG width = (clipped.right - clipped.left) & ~1L; + const LONG height = (clipped.bottom - clipped.top) & ~1L; + if (width < 2 || height < 2) return false; + + const int nextWidth = static_cast(width); + const int nextHeight = static_cast(height); + if (!cropTexture_ || nextWidth != captureWidth_ || nextHeight != captureHeight_) { + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = static_cast(nextWidth); + desc.Height = static_cast(nextHeight); + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + + ComPtr resizedTexture; + if (FAILED(d3dDevice_->CreateTexture2D(&desc, nullptr, &resizedTexture))) return false; + cropTexture_ = resizedTexture; + captureWidth_ = nextWidth; + captureHeight_ = nextHeight; + } + + const LONG left = clipped.left - monitorBounds_.left; + const LONG top = clipped.top - monitorBounds_.top; + cropRect_ = {left, top, left + width, top + height}; + return true; } void WgcSession::setFrameCallback(FrameCallback callback) { @@ -243,6 +278,7 @@ void WgcSession::stopCapture() { framePool_.Close(); framePool_ = nullptr; } + cropTexture_.Reset(); } void WgcSession::onFrameArrived( @@ -283,7 +319,16 @@ void WgcSession::onFrameArrived( HRESULT hr = access->GetInterface(IID_PPV_ARGS(&texture)); if (SUCCEEDED(hr) && texture && frameCallback_) { - frameCallback_(texture.Get(), frameTimeHns); + if (windowHandle_ && cropTexture_ && updateWindowCropRect()) { + D3D11_BOX sourceBox{ + static_cast(cropRect_.left), static_cast(cropRect_.top), 0, + static_cast(cropRect_.right), static_cast(cropRect_.bottom), 1, + }; + d3dContext_->CopySubresourceRegion(cropTexture_.Get(), 0, 0, 0, 0, texture.Get(), 0, &sourceBox); + frameCallback_(cropTexture_.Get(), frameTimeHns); + } else if (!windowHandle_) { + frameCallback_(texture.Get(), frameTimeHns); + } } frame.Close(); diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h index 1755f4b1..c2786265 100644 --- a/electron/native/wgc-capture/src/wgc_session.h +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -38,6 +38,7 @@ public: private: ComPtr d3dDevice_; ComPtr d3dContext_; + ComPtr cropTexture_; winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr}; winrt::Windows::Graphics::Capture::GraphicsCaptureItem captureItem_{nullptr}; winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr}; @@ -54,12 +55,16 @@ private: int framePoolHeight_ = 0; int64_t frameIntervalHns_ = 0; int64_t lastFrameTimeHns_ = 0; + HWND windowHandle_ = nullptr; + RECT monitorBounds_{}; + RECT cropRect_{}; bool createD3DDevice(); winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice createWinRTDevice(); winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForMonitor(HMONITOR monitor); - winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForWindow(HWND hwnd); bool initializeWithItem(int fps); + bool initializeWindowCrop(HWND hwnd); + bool updateWindowCropRect(); bool recreateFramePoolIfNeeded( winrt::Windows::Graphics::SizeInt32 const& contentSize); void onFrameArrived( diff --git a/electron/windows.ts b/electron/windows.ts index 47e90b8f..eb17b2e2 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -482,6 +482,7 @@ export function createHudOverlayWindow(): BrowserWindow { if (process.platform === "darwin") { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, + skipTransformProcessType: true, }); } diff --git a/scripts/build-native-helpers.mjs b/scripts/build-native-helpers.mjs index 778b19b8..24d0bf95 100644 --- a/scripts/build-native-helpers.mjs +++ b/scripts/build-native-helpers.mjs @@ -1,9 +1,11 @@ import { spawnSync } from "node:child_process"; import { chmod, mkdir } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; const projectRoot = process.cwd(); const nativeRoot = path.join(projectRoot, "electron", "native"); +const moduleCacheRoot = path.join(os.tmpdir(), "recordly-swift-module-cache"); if (process.platform !== "darwin") { console.log("[build-native-helpers] Skipping: host platform is not macOS."); @@ -61,6 +63,11 @@ for (const target of getTargetConfigs()) { ["-O", "-target", target.swiftTarget, sourcePath, "-o", outputPath], { encoding: "utf8", + env: { + ...process.env, + CLANG_MODULE_CACHE_PATH: path.join(moduleCacheRoot, "clang"), + SWIFT_MODULECACHE_PATH: path.join(moduleCacheRoot, "swift"), + }, timeout: 120000, }, ); diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index dcbde5ed..8ce54000 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,15 +1,15 @@ +import { AppWindowIcon, CaretUpIcon, MonitorIcon } from "@phosphor-icons/react"; import * as React from "react"; -import { MonitorIcon, AppWindowIcon, CaretUpIcon } from "@phosphor-icons/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useScopedT } from "@/contexts/I18nContext"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; import { - mapRawSource, + type DesktopSource, isScreenSource, isWindowSource, - type DesktopSource, + mapRawSource, } from "./popovers/launchPopoverTypes"; import "./launchTheme.css"; import "./SourceSelector.css"; @@ -249,6 +249,7 @@ export const SourceSelector = React.memo(function SourceSelector({ const result = await window.electronAPI.selectSource(source); if (result) { setInternalSelectedSource(source.name); + await window.electronAPI.showSourceHighlight?.(source); } } catch (error) { console.error("Failed to select source:", error); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b3c4037d..beaf6066 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -42,6 +42,7 @@ import { getMatchingCursorMotionPresetId, } from "./cursorMotionPresets"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; +import { getDefaultBorderRadiusPercent } from "./projectPersistence"; import { SliderControl } from "./SliderControl"; import { KeyboardShortcutsDialog } from "./TutorialHelp"; import type { @@ -1048,7 +1049,7 @@ export function SettingsPanel({ onCursorClickBounceDurationChange, cursorSway = DEFAULT_CURSOR_SWAY, onCursorSwayChange, - borderRadius = 12.5, + borderRadius = getDefaultBorderRadiusPercent(), onBorderRadiusChange, webcam, webcamPreviewSrc = null, @@ -2095,11 +2096,11 @@ export function SettingsPanel({ value={borderRadius} defaultValue={initialEditorPreferences.borderRadius} min={0} - max={200} - step={0.5} + max={50} + step={0.1} onChange={(v) => onBorderRadiusChange?.(v)} - formatValue={(v) => `${v}px`} - parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index c1853bad..739baaf0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/mediaTiming"; import { destroyPixiApplication, + destroyPixiContainer, initializePixiApplicationWithTimeout, } from "@/lib/pixiApplicationLifecycle"; import { @@ -1930,8 +1931,16 @@ const VideoPlayback = forwardRef( const videoEffectsContainer = videoEffectsContainerRef.current; const videoContainer = videoContainerRef.current; const cursorContainer = cursorContainerRef.current; + const cameraContainer = cameraContainerRef.current; - if (!video || !app || !videoEffectsContainer || !videoContainer || !cursorContainer) + if ( + !video || + !app || + !videoEffectsContainer || + !videoContainer || + !cursorContainer || + !cameraContainer + ) return; if (video.videoWidth === 0 || video.videoHeight === 0) return; @@ -1949,8 +1958,8 @@ const VideoPlayback = forwardRef( const maskGraphics = new Graphics(); videoContainer.addChild(videoSprite); - videoContainer.addChild(maskGraphics); - videoContainer.mask = maskGraphics; + cameraContainer.addChild(maskGraphics); + videoEffectsContainer.mask = maskGraphics; maskGraphicsRef.current = maskGraphics; if (cursorOverlayRef.current) { cursorContainer.addChild(cursorOverlayRef.current.container); @@ -1989,17 +1998,12 @@ const VideoPlayback = forwardRef( video.removeEventListener("seeking", handleSeeking); dispose(); - if (videoSprite) { - videoContainer.removeChild(videoSprite); - videoSprite.destroy(); - } - if (maskGraphics) { - videoContainer.removeChild(maskGraphics); - maskGraphics.destroy(); - } + videoEffectsContainer.mask = null; videoContainer.mask = null; + destroyPixiContainer(videoSprite); + destroyPixiContainer(maskGraphics); maskGraphicsRef.current = null; - videoTexture.destroy(false); + if (!videoTexture.destroyed) videoTexture.destroy(false); videoSpriteRef.current = null; }; @@ -2408,7 +2412,6 @@ const VideoPlayback = forwardRef( ? "absolute inset-0 h-full w-full object-cover" : "pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"; const hasRendererFallback = Boolean(pixiRendererError); - const nativeAspectRatio = (() => { const locked = lockedVideoDimensionsRef.current; if (locked) { diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 497e5e49..78481563 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -12,6 +12,29 @@ import { } from "./editorPreferences"; import { DEFAULT_AUTO_CAPTION_SETTINGS, DEFAULT_CROP_REGION } from "./types"; +describe("border radius preferences", () => { + it("migrates legacy pixels once and marks the stored unit", () => { + expect(normalizeEditorPreferences({ borderRadius: 54 })).toMatchObject({ + borderRadius: 5, + borderRadiusUnit: "percent", + }); + expect( + normalizeEditorPreferences({ borderRadius: 8, borderRadiusUnit: "percent" }), + ).toMatchObject({ borderRadius: 8, borderRadiusUnit: "percent" }); + }); + + it("only replaces a legacy zero radius on macOS", () => { + try { + vi.stubGlobal("navigator", { platform: "Win32" }); + expect(normalizeEditorPreferences({ borderRadius: 0 }).borderRadius).toBe(0); + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect(normalizeEditorPreferences({ borderRadius: 0 }).borderRadius).toBe(8); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + function createStorageMock(initialValues: Record = {}): Storage { const store = new Map(Object.entries(initialValues)); diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index b087cc88..6fee386f 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -1,5 +1,7 @@ import { loadAppSetting, saveAppSetting } from "../../lib/appSettings"; import { + getDefaultBorderRadiusPercent, + legacyBorderRadiusPixelsToPercent, normalizeExportBackendPreference, normalizeExportMp4FrameRate, normalizeExportPipelineModel, @@ -69,6 +71,7 @@ type PresetCropRegion = ProjectEditorState["cropRegion"]; type PresetWebcamSettings = Omit; export interface EditorPresetSnapshot extends Omit { + borderRadiusUnit: "percent"; cropRegion: PresetCropRegion; webcam: PresetWebcamSettings; autoCaptionSettings: PresetAutoCaptionSettings; @@ -85,6 +88,7 @@ export interface EditorPreset { } export interface EditorPreferences extends PersistedEditorControls { + borderRadiusUnit: "percent"; customAspectWidth: string; customAspectHeight: string; customWallpapers: string[]; @@ -137,6 +141,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration, cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway, borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius, + borderRadiusUnit: "percent", padding: DEFAULT_EDITOR_CONTROLS.padding, webcam: DEFAULT_EDITOR_CONTROLS.webcam, aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio, @@ -219,6 +224,7 @@ function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot return { ...normalizedControls, + borderRadiusUnit: "percent", webcam, cropRegion: normalizedCropRegion, autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings), @@ -426,9 +432,20 @@ export function normalizeEditorPreferences( ): EditorPreferences { const raw = candidate && typeof candidate === "object" ? (candidate as Partial) : {}; + const controls = + raw.borderRadiusUnit === "percent" || typeof raw.borderRadius !== "number" + ? raw + : { + ...raw, + borderRadius: + raw.borderRadius === 0 + ? getDefaultBorderRadiusPercent() + : legacyBorderRadiusPixelsToPercent(raw.borderRadius), + }; return { - ...normalizeEditorControls(raw, fallback), + ...normalizeEditorControls(controls, fallback), + borderRadiusUnit: "percent", customAspectWidth: normalizePositiveIntegerString( raw.customAspectWidth, fallback.customAspectWidth, diff --git a/src/components/video-editor/presets/useVideoEditorPresets.ts b/src/components/video-editor/presets/useVideoEditorPresets.ts index fa6a971a..5474422a 100644 --- a/src/components/video-editor/presets/useVideoEditorPresets.ts +++ b/src/components/video-editor/presets/useVideoEditorPresets.ts @@ -76,6 +76,7 @@ export function useVideoEditorPresets({ cursorClickBounceDuration: appearance.cursorClickBounceDuration, cursorSway: appearance.cursorSway, borderRadius: appearance.borderRadius, + borderRadiusUnit: "percent", padding: { ...appearance.padding }, cropRegion: { ...appearance.cropRegion }, webcam: (({ sourcePath: _sourcePath, ...settings }) => settings)(appearance.webcam), diff --git a/src/components/video-editor/project/useProjectLifecycle.ts b/src/components/video-editor/project/useProjectLifecycle.ts index 909f0cfa..a68ce56f 100644 --- a/src/components/video-editor/project/useProjectLifecycle.ts +++ b/src/components/video-editor/project/useProjectLifecycle.ts @@ -16,6 +16,8 @@ import { createProjectData, deriveNextId, fromFileUrl, + getDefaultBorderRadiusPercent, + legacyBorderRadiusPixelsToPercent, normalizeProjectEditor, resolveVideoUrl, stripPersistedDevMotionBlurSettings, @@ -83,9 +85,16 @@ export function useProjectLifecycle(input: Input) { if (!validateProjectData(candidate)) return false; const loadedProject = candidate; const sourcePath = fromFileUrl(loadedProject.videoPath); - const editor = normalizeProjectEditor( - stripPersistedDevMotionBlurSettings(loadedProject.editor ?? {}), - ); + const persistedEditor = stripPersistedDevMotionBlurSettings(loadedProject.editor ?? {}); + const editor = normalizeProjectEditor({ + ...persistedEditor, + borderRadius: + loadedProject.version < 2 && typeof persistedEditor.borderRadius === "number" + ? persistedEditor.borderRadius === 0 + ? getDefaultBorderRadiusPercent() + : legacyBorderRadiusPixelsToPercent(persistedEditor.borderRadius) + : persistedEditor.borderRadius, + }); try { current.videoPlaybackRef.current?.pause(); } catch { diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 0e7a3f04..039476bb 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { normalizeProjectEditor, resolveVideoUrl } from "./projectPersistence"; +import { + getDefaultBorderRadiusPercent, + legacyBorderRadiusPixelsToPercent, + normalizeProjectEditor, + resolveVideoUrl, +} from "./projectPersistence"; import { ADVANCED_VERTICAL_PADDING_MAX } from "./types"; afterEach(() => vi.unstubAllGlobals()); @@ -29,6 +34,20 @@ describe("resolveVideoUrl", () => { }); describe("normalizeProjectEditor", () => { + it("defaults to 8% on macOS and square corners elsewhere", () => { + expect(getDefaultBorderRadiusPercent("MacIntel")).toBe(8); + expect(getDefaultBorderRadiusPercent("Win32")).toBe(0); + expect(getDefaultBorderRadiusPercent("Linux x86_64")).toBe(0); + }); + + it("clamps radius percentages", () => { + expect(normalizeProjectEditor({ borderRadius: 75 }).borderRadius).toBe(50); + }); + + it("converts legacy 1080p-relative radius pixels to percentages", () => { + expect(legacyBorderRadiusPixelsToPercent(54)).toBe(5); + }); + it("preserves the extended advanced vertical padding range", () => { const editor = normalizeProjectEditor({ padding: { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 1a098f40..563199c0 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -82,7 +82,19 @@ import { } from "./types"; import { convertLegacyWebcamRadiusToRoundness, normalizeWebcamCropRegion } from "./webcamOverlay"; -export const PROJECT_VERSION = 1; +export const PROJECT_VERSION = 2; +export const MACOS_DEFAULT_BORDER_RADIUS_PERCENT = 8; +const LEGACY_BORDER_RADIUS_REFERENCE_PX = 1080; + +export function getDefaultBorderRadiusPercent( + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): number { + return /mac/i.test(platform) ? MACOS_DEFAULT_BORDER_RADIUS_PERCENT : 0; +} + +export function legacyBorderRadiusPixelsToPercent(value: number): number { + return (value / LEGACY_BORDER_RADIUS_REFERENCE_PX) * 100; +} const DEFAULT_MOTION_PRESET = CURSOR_MOTION_PRESETS.focused; @@ -949,7 +961,9 @@ export function normalizeProjectEditor(editor: Partial): Pro cursorSway: isFiniteNumber((editor as Partial).cursorSway) ? clamp((editor as Partial).cursorSway as number, 0, 2) : DEFAULT_CURSOR_SWAY, - borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5, + borderRadius: isFiniteNumber(editor.borderRadius) + ? clamp(editor.borderRadius, 0, 50) + : getDefaultBorderRadiusPercent(), padding: (() => { const p = editor.padding; if (p && typeof p === "object") { diff --git a/src/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts index a93db03f..72e9f9f5 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.test.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.test.ts @@ -56,10 +56,10 @@ describe("computePaddedLayout", () => { }); describe("scalePreviewBorderRadius", () => { - it("matches export scaling against the logical preview size", () => { - expect(scalePreviewBorderRadius(1920, 1080, 16)).toBeCloseTo(16, 6); - expect(scalePreviewBorderRadius(960, 540, 16)).toBeCloseTo(8, 6); - expect(scalePreviewBorderRadius(1440, 810, 16)).toBeCloseTo(12, 6); + it("uses a percentage of the content's shorter side", () => { + expect(scalePreviewBorderRadius(1920, 1080, 8)).toBeCloseTo(86.4, 6); + expect(scalePreviewBorderRadius(960, 540, 8)).toBeCloseTo(43.2, 6); + expect(scalePreviewBorderRadius(500, 1000, 8)).toBeCloseTo(40, 6); }); it("clamps invalid or empty preview sizes to zero", () => { diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index b93ef19d..7f121c1d 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -6,13 +6,16 @@ export const PADDING_SCALE_FACTOR = 0.2; export const BASE_PREVIEW_WIDTH = 1920; export const BASE_PREVIEW_HEIGHT = 1080; -export function scalePreviewBorderRadius(width: number, height: number, borderRadius = 0): number { +export function scalePreviewBorderRadius( + width: number, + height: number, + borderRadiusPercent = 0, +): number { if (width <= 0 || height <= 0) { return 0; } - const canvasScaleFactor = Math.min(width / BASE_PREVIEW_WIDTH, height / BASE_PREVIEW_HEIGHT); - return Math.max(0, borderRadius * canvasScaleFactor); + return (Math.min(width, height) * Math.min(50, Math.max(0, borderRadiusPercent))) / 100; } export function isZeroPadding(padding: Padding | number): boolean { @@ -222,7 +225,11 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { y: layout.centerOffsetY, width: layout.croppedDisplayWidth, height: layout.croppedDisplayHeight, - radius: scalePreviewBorderRadius(width, height, borderRadius), + radius: scalePreviewBorderRadius( + layout.croppedDisplayWidth, + layout.croppedDisplayHeight, + borderRadius, + ), }); maskGraphics.fill({ color: 0xffffff }); diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 32af67a9..56d5344e 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; @@ -90,6 +92,11 @@ vi.mock("./localMediaSource", () => ({ import { FrameRenderer } from "./frameRenderer"; +const rendererSource = readFileSync( + fileURLToPath(new URL("./frameRenderer.ts", import.meta.url)), + "utf8", +); + type MockFunction = ReturnType; type MockContext = { beginPath: MockFunction; @@ -123,6 +130,16 @@ type FrameRendererTestAccess = { ) => void; }; +describe("FrameRenderer mask hierarchy", () => { + it("keeps the mask in the video wrapper when camera transforms change", () => { + expect(rendererSource).toContain( + "this.cameraContainer.addChild(this.videoEffectsContainer)", + ); + expect(rendererSource).toContain("this.videoEffectsContainer.addChild(this.maskGraphics)"); + expect(rendererSource).not.toContain("this.cameraContainer.addChild(this.maskGraphics)"); + }); +}); + type Listener = { callback: () => void; once: boolean; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 1d1fc19f..0bc5abef 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -34,7 +34,10 @@ import { PixiCursorOverlay, preloadCursorAssets, } from "@/components/video-editor/videoPlayback/cursorRenderer"; -import { computePaddedLayout } from "@/components/video-editor/videoPlayback/layoutUtils"; +import { + computePaddedLayout, + scalePreviewBorderRadius, +} from "@/components/video-editor/videoPlayback/layoutUtils"; import { createSpringState, getZoomSpringConfig, @@ -230,6 +233,7 @@ function configureHighQuality2DContext( export class FrameRenderer { private app: Application | null = null; private cameraContainer: Container | null = null; + private videoEffectsContainer: Container | null = null; private videoContainer: Container | null = null; private cursorContainer: Container | null = null; private videoSprite: Sprite | null = null; @@ -396,11 +400,13 @@ export class FrameRenderer { // Setup containers this.cameraContainer = new Container(); + this.videoEffectsContainer = new Container(); this.videoContainer = new Container(); this.cursorContainer = new Container(); this.app.stage.addChild(this.cameraContainer); - this.cameraContainer.addChild(this.videoContainer); + this.cameraContainer.addChild(this.videoEffectsContainer); this.cameraContainer.addChild(this.cursorContainer); + this.videoEffectsContainer.addChild(this.videoContainer); if (cursorOverlayEnabled) { this.cursorOverlay = new PixiCursorOverlay({ @@ -496,7 +502,7 @@ export class FrameRenderer { // Setup mask this.maskGraphics = new Graphics(); - this.videoContainer.addChild(this.maskGraphics); + this.videoEffectsContainer.addChild(this.maskGraphics); this.videoContainer.mask = this.maskGraphics; if (this.cursorOverlay) { this.cursorContainer.addChild(this.cursorOverlay.container); @@ -1420,9 +1426,6 @@ export class FrameRenderer { if (this.cursorOverlay && this.cursorContainer) { this.cursorContainer.addChild(this.cursorOverlay.container); } - if (this.maskGraphics) { - this.videoContainer.addChild(this.maskGraphics); - } } else { this.videoTextureSource ??= this.videoSprite.texture .source as unknown as VideoTextureSource; @@ -1625,13 +1628,12 @@ export class FrameRenderer { this.videoContainer.position.set(0, 0); - const canvasScaleFactor = Math.min( - width / BASE_PREVIEW_WIDTH, - height / BASE_PREVIEW_HEIGHT, + const scaledBorderRadius = scalePreviewBorderRadius( + layout.croppedDisplayWidth, + layout.croppedDisplayHeight, + borderRadius, ); - const scaledBorderRadius = borderRadius * canvasScaleFactor; - this.maskGraphics.clear(); drawSquircleOnGraphics(this.maskGraphics, { x: layout.centerOffsetX, @@ -2209,6 +2211,7 @@ export class FrameRenderer { this.zoomBlurFilter?.destroy(); this.motionBlurFilter?.destroy(); this.cameraContainer = null; + this.videoEffectsContainer = null; this.videoContainer = null; this.maskGraphics = null; this.zoomBlurFilter = null; diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index fa0778ae..d5fb8f6d 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -562,7 +562,7 @@ export class FrameRenderer { this.overlayContainer.addChild(this.captionContainer); this.videoMaskGraphics = new Graphics(); - this.videoContainer.addChild(this.videoMaskGraphics); + this.videoEffectsContainer.addChild(this.videoMaskGraphics); this.videoContainer.mask = this.videoMaskGraphics; this.webcamMaskGraphics = new Graphics(); @@ -3260,7 +3260,11 @@ export class FrameRenderer { this.videoSprite.scale.set(layout.scale); this.videoSprite.position.set(layout.spriteX, layout.spriteY); - const scaledBorderRadius = scalePreviewBorderRadius(width, height, borderRadius); + const scaledBorderRadius = scalePreviewBorderRadius( + layout.croppedDisplayWidth, + layout.croppedDisplayHeight, + borderRadius, + ); this.videoMaskGraphics.clear(); drawSquircleOnGraphics(this.videoMaskGraphics, { diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 35f185de..3bb58eb8 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -2307,8 +2307,8 @@ export class ModernVideoExporter { ? null : this.getNativeStaticLayoutSourceCrop(videoInfo); const borderRadius = scalePreviewBorderRadius( - this.config.width, - this.config.height, + contentWidth, + contentHeight, this.config.borderRadius ?? 0, ); const shadowIntensity = this.config.showShadow diff --git a/src/lib/pixiApplicationLifecycle.test.ts b/src/lib/pixiApplicationLifecycle.test.ts index 6b576242..2003e9ad 100644 --- a/src/lib/pixiApplicationLifecycle.test.ts +++ b/src/lib/pixiApplicationLifecycle.test.ts @@ -2,10 +2,22 @@ import type { Application } from "pixi.js"; import { describe, expect, it, vi } from "vitest"; import { destroyPixiApplication, + destroyPixiContainer, initializePixiApplication, initializePixiApplicationWithTimeout, } from "./pixiApplicationLifecycle"; +function createContainer(destroyed = false) { + const container = { + destroyed, + destroy: vi.fn(() => { + container.destroyed = true; + }), + parent: { removeChild: vi.fn() }, + }; + return container; +} + function createApplication(init: () => Promise = async () => undefined) { return { init: vi.fn(init), @@ -16,6 +28,19 @@ function createApplication(init: () => Promise = async () => undefined) { } describe("Pixi application lifecycle", () => { + it("safely ignores display objects already destroyed by their application", () => { + const liveContainer = createContainer(); + destroyPixiContainer(liveContainer as never); + destroyPixiContainer(liveContainer as never); + expect(liveContainer.parent.removeChild).toHaveBeenCalledTimes(1); + expect(liveContainer.destroy).toHaveBeenCalledTimes(1); + + const destroyedContainer = createContainer(true); + destroyPixiContainer(destroyedContainer as never); + expect(destroyedContainer.parent.removeChild).not.toHaveBeenCalled(); + expect(destroyedContainer.destroy).not.toHaveBeenCalled(); + }); + it("cleans a failed initialization without running uninitialized plugins", async () => { const initializationError = new Error("No available renderer"); const app = createApplication(async () => { diff --git a/src/lib/pixiApplicationLifecycle.ts b/src/lib/pixiApplicationLifecycle.ts index 5abe48da..ec520528 100644 --- a/src/lib/pixiApplicationLifecycle.ts +++ b/src/lib/pixiApplicationLifecycle.ts @@ -1,4 +1,4 @@ -import type { Application } from "pixi.js"; +import type { Application, Container } from "pixi.js"; type PixiInitializationState = "initializing" | "initialized" | "failed"; type PixiInitOptions = Parameters[0]; @@ -108,3 +108,9 @@ export function destroyPixiApplication(app: Application | null, context: string) destroyContexts.set(app, context); if (initializationStates.get(app) !== "initializing") completeDestroy(app); } + +export function destroyPixiContainer(container: Container | null): void { + if (!container || container.destroyed) return; + container.parent?.removeChild(container); + container.destroy(); +}