diff --git a/electron/ipc/recording/windows.test.ts b/electron/ipc/recording/windows.test.ts index 42169462..5d691cb8 100644 --- a/electron/ipc/recording/windows.test.ts +++ b/electron/ipc/recording/windows.test.ts @@ -114,4 +114,13 @@ describe("native Windows window capture", () => { 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/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 90c38148..99980f86 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -40,6 +40,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { 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? @@ -123,7 +125,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let filter: SCContentFilter let outputWidth: Int let outputHeight: Int - let excludedProcessIds = Set(config.excludedProcessIds ?? []) + excludedProcessIds = Set(config.excludedProcessIds ?? []) let excludedApplications = availableContent.applications.filter { excludedProcessIds.contains($0.processID) } @@ -134,14 +136,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"]) } - guard let display = availableContent.displays.first(where: { - $0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY)) - }) else { - throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Window display not found"]) - } - // Accessibility reports the visible frame at the native border. - let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) let visibleFrame: CGRect if let x = config.windowX, let y = config.windowY, @@ -153,6 +148,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } else { visibleFrame = window.frame } + 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, @@ -165,6 +164,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { 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) @@ -173,6 +173,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } 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"]) @@ -637,6 +638,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { videoInput = nil videoPixelBufferAdaptor = nil windowCropRect = nil + windowCropDisplayId = nil + excludedProcessIds.removeAll() lastCroppedPixelBuffer = nil systemAudioWriter = nil systemAudioInput = nil @@ -869,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) @@ -887,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 diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index 0a7e9152..a885d75c 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -75,4 +75,12 @@ describe("ScreenCaptureKitRecorder window capture", () => { ); 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-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 5875dc52..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-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index cf79be25..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/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index 2f6ea11c..e9c53f31 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -191,17 +191,7 @@ bool WgcSession::initialize(HWND hwnd, int fps) { if (!monitor) return false; captureItem_ = createCaptureItemForMonitor(monitor); - if (!initializeWithItem(fps) || !initializeWindowCrop(hwnd)) return false; - - D3D11_TEXTURE2D_DESC desc{}; - desc.Width = static_cast(captureWidth_); - desc.Height = static_cast(captureHeight_); - desc.MipLevels = 1; - desc.ArraySize = 1; - desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; - desc.SampleDesc.Count = 1; - desc.Usage = D3D11_USAGE_DEFAULT; - return SUCCEEDED(d3dDevice_->CreateTexture2D(&desc, nullptr, &cropTexture_)); + return initializeWithItem(fps) && initializeWindowCrop(hwnd); } bool WgcSession::initializeWindowCrop(HWND hwnd) { @@ -216,8 +206,6 @@ bool WgcSession::initializeWindowCrop(HWND hwnd) { !GetWindowRect(hwnd, &windowBounds)) return false; RECT clipped{}; if (!IntersectRect(&clipped, &windowBounds, &monitorBounds_)) return false; - captureWidth_ = std::max(2L, (clipped.right - clipped.left) & ~1L); - captureHeight_ = std::max(2L, (clipped.bottom - clipped.top) & ~1L); return updateWindowCropRect(); } @@ -225,11 +213,34 @@ bool WgcSession::updateWindowCropRect() { RECT windowBounds{}; if (FAILED(DwmGetWindowAttribute(windowHandle_, DWMWA_EXTENDED_FRAME_BOUNDS, &windowBounds, sizeof(windowBounds))) && !GetWindowRect(windowHandle_, &windowBounds)) return false; - const LONG monitorWidth = monitorBounds_.right - monitorBounds_.left; - const LONG monitorHeight = monitorBounds_.bottom - monitorBounds_.top; - const LONG left = std::clamp(windowBounds.left - monitorBounds_.left, 0L, monitorWidth - captureWidth_); - const LONG top = std::clamp(windowBounds.top - monitorBounds_.top, 0L, monitorHeight - captureHeight_); - cropRect_ = {left, top, left + captureWidth_, top + captureHeight_}; + 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; } diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 2f6a6dfb..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 { @@ -1997,16 +1998,12 @@ const VideoPlayback = forwardRef( video.removeEventListener("seeking", handleSeeking); dispose(); - if (videoSprite) { - videoContainer.removeChild(videoSprite); - videoSprite.destroy(); - } - cameraContainer.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; }; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 7b6ea821..c0b3c2d5 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, @@ -1622,13 +1625,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, 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(); +}