fix: address native capture review feedback
This commit is contained in:
+107
-4
@@ -260,6 +260,7 @@ let nativeWindowsCaptureTargetPath: string | null = null;
|
||||
let nativeWindowsCaptureWebcamTargetPath: string | null = null;
|
||||
let nativeWindowsCaptureRecordingId: number | null = null;
|
||||
let nativeWindowsCursorOffsetMs = 0;
|
||||
const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000;
|
||||
|
||||
function normalizeCursorSample(sample: unknown): CursorRecordingSample | null {
|
||||
if (!sample || typeof sample !== "object") {
|
||||
@@ -681,6 +682,19 @@ function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams)
|
||||
|
||||
function waitForNativeWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
if (!proc.killed) {
|
||||
proc.kill();
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for native Windows capture to stop. Output path: ${
|
||||
nativeWindowsCaptureTargetPath ?? "unknown"
|
||||
}. Output: ${nativeWindowsCaptureOutput.trim()}`,
|
||||
),
|
||||
);
|
||||
}, NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS);
|
||||
const onOutput = (chunk: Buffer) => {
|
||||
nativeWindowsCaptureOutput += chunk.toString();
|
||||
};
|
||||
@@ -707,6 +721,7 @@ function waitForNativeWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) {
|
||||
reject(error);
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
proc.stdout.off("data", onOutput);
|
||||
proc.stderr.off("data", onOutput);
|
||||
proc.off("close", onClose);
|
||||
@@ -744,6 +759,78 @@ function setCurrentRecordingSessionState(session: RecordingSession | null) {
|
||||
currentVideoPath = session?.screenVideoPath ?? null;
|
||||
}
|
||||
|
||||
function getSessionManifestPathForVideo(videoPath: string) {
|
||||
const parsedPath = path.parse(videoPath);
|
||||
const baseName = parsedPath.name.endsWith("-webcam")
|
||||
? parsedPath.name.slice(0, -"-webcam".length)
|
||||
: parsedPath.name;
|
||||
return path.join(parsedPath.dir, `${baseName}${RECORDING_SESSION_SUFFIX}`);
|
||||
}
|
||||
|
||||
async function loadRecordedSessionForVideoPath(
|
||||
videoPath: string,
|
||||
): Promise<RecordingSession | null> {
|
||||
try {
|
||||
const manifestPath = getSessionManifestPathForVideo(videoPath);
|
||||
if (!isPathAllowed(manifestPath)) {
|
||||
const parsedVideoPath = path.parse(videoPath);
|
||||
if (!isPathWithinDir(path.resolve(manifestPath), parsedVideoPath.dir)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const content = await fs.readFile(manifestPath, "utf-8");
|
||||
const session = normalizeRecordingSession(JSON.parse(content));
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedVideoPath = normalizePath(videoPath);
|
||||
const matchesScreen = normalizePath(session.screenVideoPath) === normalizedVideoPath;
|
||||
const matchesWebcam =
|
||||
typeof session.webcamVideoPath === "string" &&
|
||||
normalizePath(session.webcamVideoPath) === normalizedVideoPath;
|
||||
if (!matchesScreen && !matchesWebcam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPathAllowed(session.screenVideoPath)) {
|
||||
const approvedScreen = await approveReadableVideoPath(session.screenVideoPath, [
|
||||
path.dirname(manifestPath),
|
||||
RECORDINGS_DIR,
|
||||
]);
|
||||
if (!approvedScreen) {
|
||||
return null;
|
||||
}
|
||||
session.screenVideoPath = approvedScreen;
|
||||
}
|
||||
|
||||
if (session.webcamVideoPath && !isPathAllowed(session.webcamVideoPath)) {
|
||||
const approvedWebcam = await approveReadableVideoPath(session.webcamVideoPath, [
|
||||
path.dirname(manifestPath),
|
||||
RECORDINGS_DIR,
|
||||
]);
|
||||
if (!approvedWebcam) {
|
||||
session.webcamVideoPath = undefined;
|
||||
} else {
|
||||
session.webcamVideoPath = approvedWebcam;
|
||||
}
|
||||
}
|
||||
|
||||
approveFilePath(session.screenVideoPath);
|
||||
if (session.webcamVideoPath) {
|
||||
approveFilePath(session.webcamVideoPath);
|
||||
}
|
||||
return session;
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code !== "ENOENT") {
|
||||
console.error("Failed to restore recording session manifest:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
createEditorWindow: () => void,
|
||||
createSourceSelectorWindow: () => BrowserWindow,
|
||||
@@ -1629,7 +1716,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("set-current-video-path", (_, path: string) => {
|
||||
ipcMain.handle("set-current-video-path", async (_, path: string) => {
|
||||
return setCurrentVideoPath(path);
|
||||
});
|
||||
|
||||
@@ -1647,10 +1734,26 @@ export function registerIpcHandlers(
|
||||
: { success: false };
|
||||
});
|
||||
|
||||
function setCurrentVideoPath(path: string): ProjectPathResult {
|
||||
currentVideoPath = normalizeVideoSourcePath(path) ?? path;
|
||||
async function setCurrentVideoPath(path: string): Promise<ProjectPathResult> {
|
||||
const normalizedPath = normalizeVideoSourcePath(path);
|
||||
if (!normalizedPath || !isPathAllowed(normalizedPath)) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Video path has not been approved",
|
||||
};
|
||||
}
|
||||
|
||||
const restoredSession = await loadRecordedSessionForVideoPath(normalizedPath);
|
||||
if (restoredSession) {
|
||||
setCurrentRecordingSessionState(restoredSession);
|
||||
} else {
|
||||
setCurrentRecordingSessionState({
|
||||
screenVideoPath: normalizedPath,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
currentProjectPath = null;
|
||||
return { success: true };
|
||||
return { success: true, path: currentVideoPath ?? normalizedPath };
|
||||
}
|
||||
|
||||
ipcMain.handle("get-current-video-path", () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface NativeBridgeContext {
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
resolveAssetBasePath: () => string | null;
|
||||
@@ -171,7 +171,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
|
||||
case "setCurrentVideoPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
projectService.setCurrentVideoPath(request.payload.path),
|
||||
await projectService.setCurrentVideoPath(request.payload.path),
|
||||
);
|
||||
case "getCurrentVideoPath":
|
||||
return createSuccessResponse(requestId, projectService.getCurrentVideoPath());
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
export function buildPowerShellCommand(sampleIntervalMs: number, windowHandle?: string | null) {
|
||||
const targetWindowHandle =
|
||||
typeof windowHandle === "string" && /^(?:0x[0-9a-fA-F]+|\d+)$/.test(windowHandle)
|
||||
? `'${windowHandle}'`
|
||||
: "$null";
|
||||
const script = String.raw`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$targetWindowHandle = ${windowHandle ? `'${windowHandle}'` : "$null"}
|
||||
$targetWindowHandle = ${targetWindowHandle}
|
||||
|
||||
$source = @"
|
||||
using System;
|
||||
|
||||
@@ -151,6 +151,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
if (payload.type === "error") {
|
||||
this.logDiagnostic("helper-error", { message: payload.message });
|
||||
console.error("Windows cursor helper error:", payload.message);
|
||||
this.failHelper(new Error(payload.message));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,6 +257,15 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
reject?.(error);
|
||||
}
|
||||
|
||||
private failHelper(error: Error) {
|
||||
this.rejectReady(error);
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
if (child && !child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
private clearReadyState() {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer);
|
||||
|
||||
@@ -38,7 +38,8 @@ export class TelemetryCursorAdapter implements CursorNativeAdapter {
|
||||
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
|
||||
if (!resolvedVideoPath) {
|
||||
return {
|
||||
success: true,
|
||||
success: false,
|
||||
message: "No video path is available for cursor telemetry",
|
||||
samples: [],
|
||||
} satisfies CursorTelemetryLoadResult;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ProjectServiceOptions {
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
}
|
||||
@@ -60,8 +60,8 @@ export class ProjectService {
|
||||
return result;
|
||||
}
|
||||
|
||||
setCurrentVideoPath(path: string) {
|
||||
const result = this.options.setCurrentVideoPath(path);
|
||||
async setCurrentVideoPath(path: string) {
|
||||
const result = await this.options.setCurrentVideoPath(path);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ bool DirectShowWebcamCapture::initialize(
|
||||
int requestedFps) {
|
||||
stop();
|
||||
delete impl_;
|
||||
impl_ = nullptr;
|
||||
impl_ = new Impl();
|
||||
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
|
||||
|
||||
@@ -402,7 +403,7 @@ void DirectShowWebcamCapture::stop() {
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::captureLoop() {
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
const HRESULT coinitHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
while (!stopRequested_ && impl_ && impl_->sampleGrabber) {
|
||||
long bufferSize = 0;
|
||||
HRESULT hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, nullptr);
|
||||
@@ -415,7 +416,9 @@ void DirectShowWebcamCapture::captureLoop() {
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / std::max(1, fps_)));
|
||||
}
|
||||
CoUninitialize();
|
||||
if (SUCCEEDED(coinitHr)) {
|
||||
CoUninitialize();
|
||||
}
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
|
||||
|
||||
@@ -463,13 +463,14 @@ int main(int argc, char* argv[]) {
|
||||
std::atomic<bool> firstFrameWritten = false;
|
||||
std::atomic<bool> encodeFailed = false;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
|
||||
int64_t latestFrameTimestampHns = 0;
|
||||
int64_t firstFrameTimestampHns = -1;
|
||||
std::vector<BYTE> latestWebcamFrame;
|
||||
int latestWebcamWidth = 0;
|
||||
int latestWebcamHeight = 0;
|
||||
bool hasVisibleWebcamFrame = false;
|
||||
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
(void)timestampHns;
|
||||
if (stopRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -490,6 +491,7 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
|
||||
session.context()->CopyResource(latestFrameTexture.Get(), texture);
|
||||
latestFrameTimestampHns = timestampHns;
|
||||
if (!firstFrameWritten.exchange(true)) {
|
||||
cv.notify_all();
|
||||
}
|
||||
@@ -498,6 +500,7 @@ int main(int argc, char* argv[]) {
|
||||
auto writeVideoFrames = [&]() {
|
||||
const auto startedAt = std::chrono::steady_clock::now();
|
||||
uint64_t frameIndex = 0;
|
||||
int64_t lastEncodedVideoTimestampHns = -1;
|
||||
|
||||
while (!stopRequested && !encodeFailed) {
|
||||
{
|
||||
@@ -519,15 +522,32 @@ int main(int argc, char* argv[]) {
|
||||
latestWebcamWidth,
|
||||
latestWebcamHeight,
|
||||
};
|
||||
const int64_t syntheticTimestampHns =
|
||||
static_cast<int64_t>((frameIndex * 10'000'000ULL) / config.fps);
|
||||
const int64_t sourceTimestampHns =
|
||||
latestFrameTimestampHns > 0 ? latestFrameTimestampHns : syntheticTimestampHns;
|
||||
if (firstFrameTimestampHns < 0) {
|
||||
firstFrameTimestampHns = sourceTimestampHns;
|
||||
}
|
||||
int64_t frameTimestampHns =
|
||||
std::max<int64_t>(0, sourceTimestampHns - firstFrameTimestampHns);
|
||||
if (lastEncodedVideoTimestampHns >= 0 &&
|
||||
frameTimestampHns <= lastEncodedVideoTimestampHns) {
|
||||
frameTimestampHns =
|
||||
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps);
|
||||
}
|
||||
if (latestFrameTexture && !encoder.writeFrame(
|
||||
latestFrameTexture.Get(),
|
||||
static_cast<int64_t>((frameIndex * 10'000'000ULL) / config.fps),
|
||||
frameTimestampHns,
|
||||
webcamFrame.data ? &webcamFrame : nullptr)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (latestFrameTexture) {
|
||||
lastEncodedVideoTimestampHns = frameTimestampHns;
|
||||
}
|
||||
}
|
||||
|
||||
frameIndex += 1;
|
||||
@@ -714,7 +734,7 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
|
||||
if (stdinThread.joinable()) {
|
||||
stdinThread.join();
|
||||
stdinThread.detach();
|
||||
}
|
||||
|
||||
if (encodeFailed) {
|
||||
|
||||
@@ -293,6 +293,8 @@ bool WasapiLoopbackCapture::start(AudioCallback callback) {
|
||||
callback_ = std::move(callback);
|
||||
stopRequested_ = false;
|
||||
writtenFrames_ = 0;
|
||||
lastDevicePositionEnd_ = 0;
|
||||
hasLastDevicePosition_ = false;
|
||||
|
||||
HRESULT hr = audioClient_->Start();
|
||||
if (!succeeded(hr, "IAudioClient::Start")) {
|
||||
@@ -324,6 +326,22 @@ const std::wstring& WasapiLoopbackCapture::selectedDeviceName() const {
|
||||
}
|
||||
|
||||
void WasapiLoopbackCapture::captureLoop() {
|
||||
auto emitSilenceFrames = [&](uint64_t frames, int64_t timestampHns) {
|
||||
constexpr uint64_t MaxSilenceChunkFrames = 4800;
|
||||
uint64_t remainingFrames = frames;
|
||||
int64_t currentTimestampHns = timestampHns;
|
||||
while (remainingFrames > 0 && !stopRequested_) {
|
||||
const uint64_t chunkFrames = std::min<uint64_t>(remainingFrames, MaxSilenceChunkFrames);
|
||||
const DWORD chunkBytes = static_cast<DWORD>(chunkFrames * inputFormat_.blockAlign);
|
||||
const int64_t chunkDurationHns =
|
||||
static_cast<int64_t>((chunkFrames * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
silenceBuffer_.assign(chunkBytes, 0);
|
||||
callback_(silenceBuffer_.data(), chunkBytes, currentTimestampHns, chunkDurationHns);
|
||||
remainingFrames -= chunkFrames;
|
||||
currentTimestampHns += chunkDurationHns;
|
||||
}
|
||||
};
|
||||
|
||||
while (!stopRequested_) {
|
||||
UINT32 packetFrames = 0;
|
||||
HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
@@ -337,17 +355,29 @@ void WasapiLoopbackCapture::captureLoop() {
|
||||
BYTE* data = nullptr;
|
||||
UINT32 framesAvailable = 0;
|
||||
DWORD flags = 0;
|
||||
UINT64 devicePosition = 0;
|
||||
UINT64 qpcPosition = 0;
|
||||
|
||||
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, nullptr, nullptr);
|
||||
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, &devicePosition, &qpcPosition);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex
|
||||
<< hr << std::dec << ")" << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
(void)qpcPosition;
|
||||
if (hasLastDevicePosition_ && devicePosition > lastDevicePositionEnd_) {
|
||||
const uint64_t gapFrames = devicePosition - lastDevicePositionEnd_;
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0 || gapFrames > framesAvailable) {
|
||||
const int64_t gapTimestampHns =
|
||||
static_cast<int64_t>((lastDevicePositionEnd_ * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
emitSilenceFrames(gapFrames, gapTimestampHns);
|
||||
}
|
||||
}
|
||||
|
||||
const DWORD byteCount = framesAvailable * inputFormat_.blockAlign;
|
||||
const int64_t timestampHns =
|
||||
static_cast<int64_t>((writtenFrames_ * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
static_cast<int64_t>((devicePosition * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
const int64_t durationHns =
|
||||
static_cast<int64_t>((static_cast<uint64_t>(framesAvailable) * HnsPerSecond) /
|
||||
inputFormat_.sampleRate);
|
||||
@@ -362,6 +392,8 @@ void WasapiLoopbackCapture::captureLoop() {
|
||||
}
|
||||
|
||||
writtenFrames_ += framesAvailable;
|
||||
lastDevicePositionEnd_ = devicePosition + framesAvailable;
|
||||
hasLastDevicePosition_ = true;
|
||||
captureClient_->ReleaseBuffer(framesAvailable);
|
||||
|
||||
hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
|
||||
@@ -55,4 +55,6 @@ private:
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::vector<BYTE> silenceBuffer_;
|
||||
uint64_t writtenFrames_ = 0;
|
||||
uint64_t lastDevicePositionEnd_ = 0;
|
||||
bool hasLastDevicePosition_ = false;
|
||||
};
|
||||
|
||||
@@ -302,7 +302,11 @@ export function LaunchWindow() {
|
||||
}
|
||||
|
||||
if (result.success && result.path) {
|
||||
await nativeBridgeClient.project.setCurrentVideoPath(result.path);
|
||||
const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
|
||||
if (!setVideoPathResult.success) {
|
||||
console.error("Failed to set current video path:", setVideoPathResult);
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,12 +119,33 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment, index) => {
|
||||
if (!segment) {
|
||||
return segment;
|
||||
}
|
||||
if (keepWindowsDrive && index === 0 && /^[a-zA-Z]:$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function toFileUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
if (normalized.match(/^[a-zA-Z]:/)) {
|
||||
return `file:///${encodeURI(normalized)}`;
|
||||
return `file:///${encodePathSegments(normalized, true)}`;
|
||||
}
|
||||
return `file://${encodeURI(normalized)}`;
|
||||
if (normalized.startsWith("//")) {
|
||||
const withoutPrefix = normalized.slice(2);
|
||||
const [host = "", ...segments] = withoutPrefix.split("/");
|
||||
return `file://${host}/${encodePathSegments(segments.join("/"))}`;
|
||||
}
|
||||
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodePathSegments(absolutePath)}`;
|
||||
}
|
||||
|
||||
export function fromFileUrl(fileUrl: string): string {
|
||||
|
||||
@@ -721,6 +721,8 @@ export class PixiCursorOverlay {
|
||||
}
|
||||
this.cursorMotionBlurFilter.destroy();
|
||||
this.container.destroy({ children: true });
|
||||
cursorAssetsPromise = null;
|
||||
loadedCursorAssets = {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -644,7 +644,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
finalizing: false,
|
||||
};
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = result.recordingId;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
@@ -918,7 +918,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = activeRecordingId;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
|
||||
@@ -145,6 +145,7 @@ export class FrameRenderer {
|
||||
private threeDPass: ThreeDPass | null = null;
|
||||
private currentRotation3D: Rotation3D = { ...DEFAULT_ROTATION_3D };
|
||||
private cursorImageCache = new Map<string, HTMLImageElement>();
|
||||
private warnedKeys = new Set<string>();
|
||||
private config: FrameRenderConfig;
|
||||
private animationState: AnimationState;
|
||||
private layoutCache: LayoutCache | null = null;
|
||||
@@ -585,7 +586,13 @@ export class FrameRenderer {
|
||||
1,
|
||||
activeNativeCursor.sample,
|
||||
);
|
||||
const image = await this.getCursorImage(renderAsset);
|
||||
let image: HTMLImageElement;
|
||||
try {
|
||||
image = await this.getCursorImage(renderAsset);
|
||||
} catch (error) {
|
||||
this.warnOnce("native-cursor-image-load", "Failed to load native cursor asset", error);
|
||||
return;
|
||||
}
|
||||
const scale = Math.max(0, this.config.cursorScale ?? 1);
|
||||
const appliedScale = this.animationState.appliedScale;
|
||||
const canvasX = projectedPoint.x * appliedScale + this.animationState.x;
|
||||
@@ -616,6 +623,14 @@ export class FrameRenderer {
|
||||
return image;
|
||||
}
|
||||
|
||||
private warnOnce(key: string, message: string, error: unknown) {
|
||||
if (this.warnedKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.warnedKeys.add(key);
|
||||
console.warn(`[FrameRenderer] ${message}:`, error);
|
||||
}
|
||||
|
||||
private updateLayout(webcamFrame?: VideoFrame | null): void {
|
||||
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { once } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -18,12 +19,16 @@ async function launchApp() {
|
||||
MAIN_JS,
|
||||
"--no-sandbox",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--lang=en-US",
|
||||
`--user-data-dir=${testUserDataDir}`,
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_USER_DATA_DIR: testUserDataDir,
|
||||
HEADLESS: process.env["HEADLESS"] ?? "true",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LANGUAGE: "en_US",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,13 +58,24 @@ async function closeApp(app: ElectronApplication) {
|
||||
}
|
||||
).__childProcess;
|
||||
await Promise.race([app.close(), new Promise<void>((resolve) => setTimeout(resolve, 5_000))]);
|
||||
if (childProcess && !childProcess.killed) {
|
||||
childProcess.kill();
|
||||
if (childProcess && childProcess.exitCode === null && childProcess.signalCode === null) {
|
||||
if (!childProcess.killed) {
|
||||
childProcess.kill();
|
||||
}
|
||||
await Promise.race([
|
||||
once(childProcess, "close"),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
||||
]);
|
||||
}
|
||||
const testUserDataDir = (app as ElectronApplication & { __testUserDataDir?: string })
|
||||
.__testUserDataDir;
|
||||
if (testUserDataDir && fs.existsSync(testUserDataDir)) {
|
||||
fs.rmSync(testUserDataDir, { recursive: true, force: true });
|
||||
fs.rmSync(testUserDataDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user