mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Merge pull request #877 from webadderallorg/codex/fix-window-recording-crop
Record selected windows by cropping full-screen captures
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -62,7 +62,6 @@ export async function resolveRecordingSessionManifest(
|
||||
typeof parsed.webcamFileName === "string" && parsed.webcamFileName.trim()
|
||||
? parsed.webcamFileName.trim()
|
||||
: null;
|
||||
|
||||
if (!webcamFileName) {
|
||||
return {
|
||||
videoPath: normalizedVideoPath,
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
loadProjectFromPath,
|
||||
loadRecentProjectPaths,
|
||||
persistRecordingsDirectorySetting,
|
||||
rememberApprovedLocalReadPath,
|
||||
rememberRecentProject,
|
||||
replaceApprovedSessionLocalReadPaths,
|
||||
rememberApprovedLocalReadPath,
|
||||
resolveApprovedLocalMediaPath,
|
||||
saveProjectThumbnail,
|
||||
saveRecentProjectPaths,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -24,4 +24,5 @@ target_link_libraries(wgc-capture PRIVATE
|
||||
mfuuid
|
||||
ole32
|
||||
shcore
|
||||
dwmapi
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -482,6 +482,7 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
if (process.platform === "darwin") {
|
||||
win.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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