Keep window crops aligned as windows change

This commit is contained in:
young
2026-09-04 20:06:05 +10:00
parent ed32f05f52
commit a5bbfa8e43
10 changed files with 150 additions and 42 deletions
+9
View File
@@ -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)",
);
});
});
+60 -10
View File
@@ -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<Int32>()
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<Void, Never>) 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
@@ -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");
});
});
+29 -18
View File
@@ -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<UINT>(captureWidth_);
desc.Height = static_cast<UINT>(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<int>(width);
const int nextHeight = static_cast<int>(height);
if (!cropTexture_ || nextWidth != captureWidth_ || nextHeight != captureHeight_) {
D3D11_TEXTURE2D_DESC desc{};
desc.Width = static_cast<UINT>(nextWidth);
desc.Height = static_cast<UINT>(nextHeight);
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
desc.SampleDesc.Count = 1;
desc.Usage = D3D11_USAGE_DEFAULT;
ComPtr<ID3D11Texture2D> 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;
}
@@ -21,6 +21,7 @@ import {
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
destroyPixiContainer,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import {
@@ -1997,16 +1998,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
};
+8 -6
View File
@@ -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,
+25
View File
@@ -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<void> = async () => undefined) {
return {
init: vi.fn(init),
@@ -16,6 +28,19 @@ function createApplication(init: () => Promise<void> = 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 () => {
+7 -1
View File
@@ -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<Application["init"]>[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();
}