Merge pull request #877 from webadderallorg/codex/fix-window-recording-crop

Record selected windows by cropping full-screen captures
This commit is contained in:
webadderall
2026-09-04 20:26:27 +10:00
committed by GitHub
38 changed files with 662 additions and 175 deletions
+11 -1
View File
@@ -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(
-1
View File
@@ -62,7 +62,6 @@ export async function resolveRecordingSessionManifest(
typeof parsed.webcamFileName === "string" && parsed.webcamFileName.trim()
? parsed.webcamFileName.trim()
: null;
if (!webcamFileName) {
return {
videoPath: normalizedVideoPath,
+24
View File
@@ -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)",
);
});
});
+1 -1
View File
@@ -16,9 +16,9 @@ import {
loadProjectFromPath,
loadRecentProjectPaths,
persistRecordingsDirectorySetting,
rememberApprovedLocalReadPath,
rememberRecentProject,
replaceApprovedSessionLocalReadPaths,
rememberApprovedLocalReadPath,
resolveApprovedLocalMediaPath,
saveProjectThumbnail,
saveRecentProjectPaths,
+14 -1
View File
@@ -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);
+124 -63
View File
@@ -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<WindowBounds | null> {
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();
+178 -27
View File
@@ -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<Int32>()
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<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
@@ -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
@@ -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");
});
});
Binary file not shown.
@@ -24,4 +24,5 @@ target_link_libraries(wgc-capture PRIVATE
mfuuid
ole32
shcore
dwmapi
)
+68 -23
View File
@@ -2,6 +2,7 @@
#include <windows.graphics.capture.interop.h>
#include <Windows.Graphics.Capture.h>
#include <dwmapi.h>
#include <inspectable.h>
#include <winrt/Windows.Foundation.h>
@@ -9,6 +10,7 @@
#include <iostream>
#include <chrono>
#include <algorithm>
// 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<IGraphicsCaptureItemInterop>();
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{nullptr};
HRESULT hr = interop->CreateForWindow(
hwnd,
winrt::guid_of<ABI::Windows::Graphics::Capture::IGraphicsCaptureItem>(),
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<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;
}
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<UINT>(cropRect_.left), static_cast<UINT>(cropRect_.top), 0,
static_cast<UINT>(cropRect_.right), static_cast<UINT>(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();
@@ -38,6 +38,7 @@ public:
private:
ComPtr<ID3D11Device> d3dDevice_;
ComPtr<ID3D11DeviceContext> d3dContext_;
ComPtr<ID3D11Texture2D> 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(
+1
View File
@@ -482,6 +482,7 @@ export function createHudOverlayWindow(): BrowserWindow {
if (process.platform === "darwin") {
win.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: true,
});
}
+7
View File
@@ -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,
},
);
+6 -5
View File
@@ -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);
@@ -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(/%$/, ""))}
/>
<div className="flex flex-col gap-1.5 pt-0.5">
<div className="flex items-center justify-between">
+16 -13
View File
@@ -21,6 +21,7 @@ import {
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
destroyPixiContainer,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import {
@@ -1930,8 +1931,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
? "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) {
@@ -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<string, string> = {}): Storage {
const store = new Map(Object.entries(initialValues));
@@ -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<ProjectEditorState["webcam"], "sourcePath">;
export interface EditorPresetSnapshot extends Omit<PersistedEditorControls, "webcam"> {
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<EditorPreferences>) : {};
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,
@@ -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),
@@ -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 {
@@ -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: {
@@ -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<ProjectEditorState>): Pro
cursorSway: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorSway)
? clamp((editor as Partial<ProjectEditorState>).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") {
@@ -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", () => {
@@ -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 });
+17
View File
@@ -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<typeof vi.fn>;
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;
+14 -11
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,
@@ -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;
+6 -2
View File
@@ -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, {
+2 -2
View File
@@ -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
+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();
}