Merge pull request #588 from webadderallorg/fix/video-resolution-quality-preview-export

fix: stabilize Windows recording preview
This commit is contained in:
Phạm Thị Minh Hồng authored and GitHub committed 2026-05-25 03:02:41 +07:00
commit 089bb5df01
7 files changed
+226 -31

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setWindowsCaptureOutputBuffer, setWindowsCaptureTargetPath } from "../state";
import { waitForWindowsCaptureStop } from "./windows";
vi.mock("electron", () => ({
app: {
getPath: () => "C:\\RecordlyTest",
},
BrowserWindow: {
getAllWindows: () => [],
},
}));
class FakeCaptureProcess extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
stdin = new PassThrough();
killed = false;
kill = vi.fn(() => {
this.killed = true;
return true;
});
}
describe("waitForWindowsCaptureStop", () => {
beforeEach(() => {
setWindowsCaptureOutputBuffer("");
setWindowsCaptureTargetPath(null);
});
it("resolves the helper output path when the process closes cleanly", async () => {
const proc = new FakeCaptureProcess();
setWindowsCaptureOutputBuffer("Recording stopped. Output path: C:\\Recordly\\capture.mp4");
const stopped = waitForWindowsCaptureStop(
proc as unknown as Parameters<typeof waitForWindowsCaptureStop>[0],
1000,
);
proc.emit("close", 0);
await expect(stopped).resolves.toBe("C:\\Recordly\\capture.mp4");
expect(proc.kill).not.toHaveBeenCalled();
});
it("resolves the fallback target path when the helper closes cleanly without output path", async () => {
const proc = new FakeCaptureProcess();
setWindowsCaptureOutputBuffer("Recording stopped without output path");
setWindowsCaptureTargetPath("C:\\Recordly\\fallback.mp4");
const stopped = waitForWindowsCaptureStop(
proc as unknown as Parameters<typeof waitForWindowsCaptureStop>[0],
1000,
);
proc.emit("close", 0);
await expect(stopped).resolves.toBe("C:\\Recordly\\fallback.mp4");
expect(proc.kill).not.toHaveBeenCalled();
});
it("rejects with helper output when the helper exits with a non-zero code", async () => {
const proc = new FakeCaptureProcess();
setWindowsCaptureOutputBuffer("Encoder error: insufficient memory");
const stopped = waitForWindowsCaptureStop(
proc as unknown as Parameters<typeof waitForWindowsCaptureStop>[0],
1000,
);
proc.emit("close", 1);
await expect(stopped).rejects.toThrow("Encoder error: insufficient memory");
expect(proc.kill).not.toHaveBeenCalled();
});
it("rejects when the helper emits an error", async () => {
const proc = new FakeCaptureProcess();
const error = new Error("spawn failed");
const stopped = waitForWindowsCaptureStop(
proc as unknown as Parameters<typeof waitForWindowsCaptureStop>[0],
1000,
);
proc.emit("error", error);
await expect(stopped).rejects.toBe(error);
expect(proc.kill).not.toHaveBeenCalled();
});
it("kills the helper and rejects when stop never completes", async () => {
const proc = new FakeCaptureProcess();
await expect(
waitForWindowsCaptureStop(
proc as unknown as Parameters<typeof waitForWindowsCaptureStop>[0],
5,
),
).rejects.toThrow("Timed out waiting for native Windows capture to stop");
expect(proc.kill).toHaveBeenCalledTimes(1);
});
});
+47 -20
View File
@@ -16,8 +16,10 @@ import {
import {
AudioSyncAdjustment,
} from "../types";
import { emitRecordingInterrupted } from "./events";
import { moveFileWithOverwrite } from "../utils";
import { emitRecordingInterrupted } from "./events";
const WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 45_000;
export type NativeWindowsVideoPaddingResult = {
padded: boolean;
@@ -107,33 +109,58 @@ export function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams)
});
}
export function waitForWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) {
export function waitForWindowsCaptureStop(
proc: ChildProcessWithoutNullStreams,
timeoutMs = WINDOWS_CAPTURE_STOP_TIMEOUT_MS,
) {
return new Promise<string>((resolve, reject) => {
const onClose = (code: number | null) => {
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
cleanup();
const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/);
if (match?.[1]) {
resolve(match[1].trim());
return;
}
if (code === 0 && windowsCaptureTargetPath) {
resolve(windowsCaptureTargetPath);
return;
}
reject(
new Error(
windowsCaptureOutputBuffer.trim() ||
`Native Windows capture exited with code ${code ?? "unknown"}`,
),
);
callback();
};
const timer = setTimeout(() => {
finish(() => {
try {
if (!proc.killed) proc.kill();
} catch {
// The process may already be gone; the caller only needs the timeout error.
}
reject(new Error("Timed out waiting for native Windows capture to stop"));
});
}, timeoutMs);
const onClose = (code: number | null) => {
finish(() => {
const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/);
if (match?.[1]) {
resolve(match[1].trim());
return;
}
if (code === 0 && windowsCaptureTargetPath) {
resolve(windowsCaptureTargetPath);
return;
}
reject(
new Error(
windowsCaptureOutputBuffer.trim() ||
`Native Windows capture exited with code ${code ?? "unknown"}`,
),
);
});
};
const onError = (error: Error) => {
cleanup();
reject(error);
finish(() => {
reject(error);
});
};
const cleanup = () => {
clearTimeout(timer);
proc.off("close", onClose);
proc.off("error", onError);
};
@@ -5,10 +5,10 @@
"helpers": {
"wgc-capture": {
"binaryName": "wgc-capture.exe",
"binarySha256": "47a67765a0b7fdd9936e41a2f7e62f6408bd426774f7854e6378194ebe3ae827",
"binarySha256": "298b41f371c3881046061048b466e12ed70dd93fa761bade2fd57d1ccddf3cb9",
"sourceDir": "electron/native/wgc-capture",
"sourceFingerprint": "696c9f56da75105941063b7940eb91c231e781e9a3a4ba885e47bcfdf798f13f",
"updatedAt": "2026-05-22T10:43:18.337Z"
"sourceFingerprint": "6ee457080c27dc939ff4b61965f86b6d73995e40200440a1dc44865f9708d39f",
"updatedAt": "2026-05-24T19:49:15.077Z"
},
"cursor-monitor": {
"binaryName": "cursor-monitor.exe",
Binary file not shown.
+24 -1
View File
@@ -3,6 +3,7 @@
#include <mferror.h>
#include <codecapi.h>
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <cstring>
@@ -15,6 +16,25 @@ static int clampByte(int v) {
return v < 0 ? 0 : (v > 255 ? 255 : v);
}
static UINT32 calculateScreenRecordingBitrate(int width, int height, int fps) {
constexpr uint64_t kFourKPixels = 3840ULL * 2160ULL;
constexpr uint64_t kQhdPixels = 2560ULL * 1440ULL;
constexpr UINT32 kBitrate4K = 45000000;
constexpr UINT32 kBitrateQhd = 28000000;
constexpr UINT32 kBitrateBase = 18000000;
constexpr double kHighFrameRateBoost = 1.35;
const uint64_t pixels =
static_cast<uint64_t>((std::max)(width, 1)) *
static_cast<uint64_t>((std::max)(height, 1));
const UINT32 baseBitrate =
pixels >= kFourKPixels ? kBitrate4K :
pixels >= kQhdPixels ? kBitrateQhd :
kBitrateBase;
const double boost = fps >= 60 ? kHighFrameRateBoost : 1.0;
return static_cast<UINT32>(static_cast<double>(baseBitrate) * boost + 0.5);
}
MFEncoder::MFEncoder() {}
MFEncoder::~MFEncoder() {
@@ -56,11 +76,14 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);
outputType->SetUINT32(MF_MT_AVG_BITRATE, 20000000);
const UINT32 videoBitrate = calculateScreenRecordingBitrate(width_, height_, fps_);
outputType->SetUINT32(MF_MT_AVG_BITRATE, videoBitrate);
MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, width_, height_);
MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1);
MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
std::cerr << "Encoder bitrate: " << videoBitrate << " bps for "
<< width_ << "x" << height_ << "@" << fps_ << "fps" << std::endl;
// Input media type (NV12)
ComPtr<IMFMediaType> inputType;
@@ -2064,6 +2064,35 @@ export default function VideoEditor() {
[currentProjectPath, webcam.timeOffsetMs],
);
const resetSourceScopedEditorState = useCallback(() => {
setZoomRegions([]);
setTrimRegions([]);
setClipRegions([]);
clipInitializedRef.current = false;
autoFullTrackClipIdRef.current = null;
autoFullTrackClipEndMsRef.current = null;
setSpeedRegions([]);
setAnnotationRegions([]);
setAudioRegions([]);
setSourceAudioTrackSettingsByClip({});
setDefaultSourceAudioTrackSettings({});
setHasClipSourceAudio(false);
setAutoCaptions([]);
setAutoCaptionSettings((prev) => ({ ...prev, enabled: false }));
setSelectedZoomId(null);
setSelectedClipId(null);
setSelectedAnnotationId(null);
setSelectedAudioId(null);
nextZoomIdRef.current = 1;
nextClipIdRef.current = 1;
nextAudioIdRef.current = 1;
nextAnnotationIdRef.current = 1;
nextAnnotationZIndexRef.current = 1;
resetEditorHistoryStack(editorHistoryRef.current);
applyingHistoryRef.current = false;
syncHistoryButtons();
}, [syncHistoryButtons]);
const handleUploadWebcam = useCallback(async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (!result.success || !result.path) {
@@ -2152,6 +2181,7 @@ export default function VideoEditor() {
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
resetSourceScopedEditorState();
pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
? sourceVideoUrl
: null;
@@ -2180,6 +2210,7 @@ export default function VideoEditor() {
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
resetSourceScopedEditorState();
pendingFreshRecordingAutoZoomPathRef.current = null;
setWebcam((prev) => ({
...prev,
@@ -2237,6 +2268,7 @@ export default function VideoEditor() {
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
resetSourceScopedEditorState();
pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
? sourceVideoUrl
: null;
@@ -2259,6 +2291,7 @@ export default function VideoEditor() {
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
resetSourceScopedEditorState();
pendingFreshRecordingAutoZoomPathRef.current = null;
applySessionPresentation(null);
setWebcam((prev) => ({
@@ -2285,6 +2318,7 @@ export default function VideoEditor() {
devOpenRecordingConfig.inputPath,
devOpenRecordingConfig.webcamInputPath,
initialEditorPreferences,
resetSourceScopedEditorState,
smokeExportConfig.enabled,
smokeExportConfig.inputPath,
smokeExportConfig.projectPath,
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { SOURCE_AUDIO_FALLBACK_TOAST_ID } from "@/components/video-editor/audio/audioTypes";
@@ -16,13 +16,18 @@ export function useSourceAudioFallback({
const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState<string[]>([]);
const [sourceAudioFallbackStartDelayMsByPath, setSourceAudioFallbackStartDelayMsByPath] =
useState<Record<string, number>>({});
const previousSourcePathRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
// Refetch when late recording sidecars are finalized after the editor opens.
void refreshKey;
setSourceAudioFallbackPaths([]);
setSourceAudioFallbackStartDelayMsByPath({});
const sourceChanged = previousSourcePathRef.current !== currentSourcePath;
previousSourcePathRef.current = currentSourcePath;
if (sourceChanged) {
setSourceAudioFallbackPaths([]);
setSourceAudioFallbackStartDelayMsByPath({});
}
if (!currentSourcePath) {
return () => {
@@ -37,8 +42,10 @@ export function useSourceAudioFallback({
return;
}
if (!result.success) {
setSourceAudioFallbackPaths([]);
setSourceAudioFallbackStartDelayMsByPath({});
if (sourceChanged) {
setSourceAudioFallbackPaths([]);
setSourceAudioFallbackStartDelayMsByPath({});
}
toast.warning(
result.error
? `Could not load companion audio sources: ${summarizeErrorMessage(result.error)}`
@@ -53,8 +60,10 @@ export function useSourceAudioFallback({
setSourceAudioFallbackStartDelayMsByPath(result.startDelayMsByPath ?? {});
} catch (error) {
if (!cancelled) {
setSourceAudioFallbackPaths([]);
setSourceAudioFallbackStartDelayMsByPath({});
if (sourceChanged) {
setSourceAudioFallbackPaths([]);
setSourceAudioFallbackStartDelayMsByPath({});
}
toast.warning(
`Could not load companion audio sources: ${summarizeErrorMessage(String(error))}`,
{ id: SOURCE_AUDIO_FALLBACK_TOAST_ID, duration: 10000 },