mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
Keep window crops aligned as windows change
This commit is contained in:
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user