mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
Merge pull request #588 from webadderallorg/fix/video-resolution-quality-preview-export
fix: stabilize Windows recording preview
This commit is contained in:
commit
089bb5df01
7 files changed
+226
-31
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user