mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-02 21:55:16 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a50835e30f | ||
|
|
086039bf67 | ||
|
|
3b7e78a16b | ||
|
|
97fa74aa92 | ||
|
|
0daf2295a3 | ||
|
|
ca826d9088 | ||
|
|
b5c105b5d6 |
Vendored
+8
@@ -117,6 +117,14 @@ interface Window {
|
||||
discarded?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
pauseNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeMacRecording: (
|
||||
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
|
||||
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
|
||||
|
||||
@@ -384,6 +384,10 @@ let nativeWindowsCaptureWebcamTargetPath: string | null = null;
|
||||
let nativeWindowsCaptureRecordingId: number | null = null;
|
||||
let nativeWindowsCursorOffsetMs = 0;
|
||||
let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay";
|
||||
let nativeWindowsCursorRecordingStartMs = 0;
|
||||
let nativeWindowsPauseStartedAtMs: number | null = null;
|
||||
let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = [];
|
||||
let nativeWindowsIsPaused = false;
|
||||
const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000;
|
||||
let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null;
|
||||
let nativeMacCaptureOutput = "";
|
||||
@@ -873,6 +877,18 @@ function completeNativeMacCursorPauseRange(endMs = Date.now()) {
|
||||
nativeMacPauseStartedAtMs = null;
|
||||
}
|
||||
|
||||
function completeNativeWindowsCursorPauseRange(endMs = Date.now()) {
|
||||
if (nativeWindowsPauseStartedAtMs === null || nativeWindowsCursorRecordingStartMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
nativeWindowsPauseRanges.push({
|
||||
startMs: Math.max(0, nativeWindowsPauseStartedAtMs - nativeWindowsCursorRecordingStartMs),
|
||||
endMs: Math.max(0, endMs - nativeWindowsCursorRecordingStartMs),
|
||||
});
|
||||
nativeWindowsPauseStartedAtMs = null;
|
||||
}
|
||||
|
||||
function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -1583,9 +1599,14 @@ export function registerIpcHandlers(
|
||||
nativeWindowsCaptureRecordingId = recordingId;
|
||||
nativeWindowsCursorOffsetMs = 0;
|
||||
nativeWindowsCursorCaptureMode = cursorCaptureMode;
|
||||
nativeWindowsCursorRecordingStartMs = 0;
|
||||
nativeWindowsPauseStartedAtMs = null;
|
||||
nativeWindowsPauseRanges = [];
|
||||
nativeWindowsIsPaused = false;
|
||||
|
||||
const cursorStartTimeMs = Date.now();
|
||||
if (cursorCaptureMode === "editable-overlay") {
|
||||
nativeWindowsCursorRecordingStartMs = cursorStartTimeMs;
|
||||
await startCursorRecording(cursorStartTimeMs);
|
||||
console.info("[native-wgc] cursor sampler ready", {
|
||||
cursorStartTimeMs,
|
||||
@@ -1635,6 +1656,10 @@ export function registerIpcHandlers(
|
||||
nativeWindowsCaptureRecordingId = null;
|
||||
nativeWindowsCursorOffsetMs = 0;
|
||||
nativeWindowsCursorCaptureMode = "editable-overlay";
|
||||
nativeWindowsCursorRecordingStartMs = 0;
|
||||
nativeWindowsPauseStartedAtMs = null;
|
||||
nativeWindowsPauseRanges = [];
|
||||
nativeWindowsIsPaused = false;
|
||||
await stopCursorRecording();
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
@@ -1836,6 +1861,50 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("pause-native-windows-recording", async () => {
|
||||
const proc = nativeWindowsCaptureProcess;
|
||||
if (!proc) {
|
||||
return { success: false, error: "Native Windows capture is not running." };
|
||||
}
|
||||
if (nativeWindowsIsPaused) {
|
||||
return { success: true };
|
||||
}
|
||||
if (!proc.stdin.writable) {
|
||||
return { success: false, error: "Native Windows capture command channel is closed." };
|
||||
}
|
||||
|
||||
try {
|
||||
proc.stdin.write("pause\n");
|
||||
nativeWindowsIsPaused = true;
|
||||
nativeWindowsPauseStartedAtMs = Date.now();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("resume-native-windows-recording", async () => {
|
||||
const proc = nativeWindowsCaptureProcess;
|
||||
if (!proc) {
|
||||
return { success: false, error: "Native Windows capture is not running." };
|
||||
}
|
||||
if (!nativeWindowsIsPaused) {
|
||||
return { success: true };
|
||||
}
|
||||
if (!proc.stdin.writable) {
|
||||
return { success: false, error: "Native Windows capture command channel is closed." };
|
||||
}
|
||||
|
||||
try {
|
||||
proc.stdin.write("resume\n");
|
||||
completeNativeWindowsCursorPauseRange();
|
||||
nativeWindowsIsPaused = false;
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => {
|
||||
const proc = nativeWindowsCaptureProcess;
|
||||
const preferredPath = nativeWindowsCaptureTargetPath;
|
||||
@@ -1848,6 +1917,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
|
||||
try {
|
||||
completeNativeWindowsCursorPauseRange();
|
||||
const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc);
|
||||
proc.stdin.write("stop\n");
|
||||
const stoppedPath = await stoppedPathPromise;
|
||||
@@ -1872,6 +1942,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
|
||||
if (cursorCaptureMode === "editable-overlay") {
|
||||
compactPendingCursorTelemetryPauseRanges(nativeWindowsPauseRanges);
|
||||
shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs);
|
||||
await writePendingCursorTelemetry(screenVideoPath);
|
||||
}
|
||||
@@ -1913,6 +1984,10 @@ export function registerIpcHandlers(
|
||||
nativeWindowsCaptureRecordingId = null;
|
||||
nativeWindowsCursorOffsetMs = 0;
|
||||
nativeWindowsCursorCaptureMode = "editable-overlay";
|
||||
nativeWindowsCursorRecordingStartMs = 0;
|
||||
nativeWindowsPauseStartedAtMs = null;
|
||||
nativeWindowsPauseRanges = [];
|
||||
nativeWindowsIsPaused = false;
|
||||
const source = selectedSource || { name: "Screen" };
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(false, source.name);
|
||||
|
||||
@@ -279,6 +279,7 @@ bool AudioMixer::start() {
|
||||
stopRequested_ = false;
|
||||
emittedFrames_ = 0;
|
||||
timelineStarted_ = false;
|
||||
paused_ = false;
|
||||
thread_ = std::thread([this] {
|
||||
mixLoop();
|
||||
});
|
||||
@@ -296,6 +297,18 @@ void AudioMixer::beginTimeline() {
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::setPaused(bool paused) {
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
paused_ = paused;
|
||||
if (paused_) {
|
||||
systemQueue_.clear();
|
||||
microphoneQueue_.clear();
|
||||
}
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::stop() {
|
||||
stopRequested_ = true;
|
||||
cv_.notify_all();
|
||||
@@ -311,6 +324,9 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) {
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
append(systemQueue_, data, byteCount, systemFormat_, 1.0);
|
||||
}
|
||||
cv_.notify_all();
|
||||
@@ -323,6 +339,9 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) {
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_);
|
||||
}
|
||||
cv_.notify_all();
|
||||
@@ -371,13 +390,13 @@ void AudioMixer::mixLoop() {
|
||||
const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes;
|
||||
const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty();
|
||||
return stopRequested_.load() ||
|
||||
(timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource);
|
||||
(timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource);
|
||||
});
|
||||
|
||||
if (stopRequested_) {
|
||||
break;
|
||||
}
|
||||
if (!timelineStarted_) {
|
||||
if (!timelineStarted_ || paused_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ public:
|
||||
|
||||
bool start();
|
||||
void beginTimeline();
|
||||
void setPaused(bool paused);
|
||||
void stop();
|
||||
void pushSystem(const BYTE* data, DWORD byteCount);
|
||||
void pushMicrophone(const BYTE* data, DWORD byteCount);
|
||||
@@ -81,5 +82,6 @@ private:
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
bool timelineStarted_ = false;
|
||||
bool paused_ = false;
|
||||
uint64_t emittedFrames_ = 0;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <condition_variable>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -50,6 +51,37 @@ struct CaptureConfig {
|
||||
int webcamFps = 0;
|
||||
};
|
||||
|
||||
struct CaptureControl {
|
||||
std::atomic<bool> stopRequested = false;
|
||||
std::atomic<bool> paused = false;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::chrono::steady_clock::time_point pauseStartedAt;
|
||||
std::chrono::steady_clock::duration totalPausedDuration{};
|
||||
|
||||
int64_t pausedDurationHns() {
|
||||
std::scoped_lock lock(mutex);
|
||||
auto total = totalPausedDuration;
|
||||
if (paused.load()) {
|
||||
total += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(total).count() / 100;
|
||||
}
|
||||
|
||||
void setPaused(bool nextPaused) {
|
||||
std::scoped_lock lock(mutex);
|
||||
if (nextPaused == paused.load()) {
|
||||
return;
|
||||
}
|
||||
if (nextPaused) {
|
||||
pauseStartedAt = std::chrono::steady_clock::now();
|
||||
} else {
|
||||
totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
paused = nextPaused;
|
||||
}
|
||||
};
|
||||
|
||||
std::wstring utf8ToWide(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
@@ -319,17 +351,31 @@ bool parseConfig(const std::string& json, CaptureConfig& config) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void readStopCommands(std::atomic<bool>& stopRequested, std::condition_variable& cv) {
|
||||
void readCaptureCommands(CaptureControl& control, const std::function<void(bool)>& onPauseChanged) {
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
if (line == "stop" || line == "q" || line == "quit") {
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (line == "pause") {
|
||||
control.setPaused(true);
|
||||
onPauseChanged(true);
|
||||
std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
if (line == "resume") {
|
||||
control.setPaused(false);
|
||||
onPauseChanged(false);
|
||||
std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -489,8 +535,7 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::atomic<bool> stopRequested = false;
|
||||
CaptureControl control;
|
||||
std::atomic<bool> firstFrameWritten = false;
|
||||
std::atomic<bool> encodeFailed = false;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
|
||||
@@ -503,7 +548,7 @@ int main(int argc, char* argv[]) {
|
||||
bool hasVisibleWebcamFrame = false;
|
||||
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
if (stopRequested) {
|
||||
if (control.stopRequested || control.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -516,8 +561,8 @@ int main(int argc, char* argv[]) {
|
||||
desc.MiscFlags = 0;
|
||||
if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -525,20 +570,29 @@ int main(int argc, char* argv[]) {
|
||||
session.context()->CopyResource(latestFrameTexture.Get(), texture);
|
||||
latestFrameTimestampHns = timestampHns;
|
||||
if (!firstFrameWritten.exchange(true)) {
|
||||
cv.notify_all();
|
||||
control.cv.notify_all();
|
||||
}
|
||||
});
|
||||
|
||||
auto writeVideoFrames = [&]() {
|
||||
const auto startedAt = std::chrono::steady_clock::now();
|
||||
const auto frameDuration = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(1.0 / config.fps));
|
||||
uint64_t frameIndex = 0;
|
||||
uint64_t lastWrittenWebcamSequence = 0;
|
||||
uint64_t webcamOutputFrameIndex = 0;
|
||||
int64_t lastEncodedVideoTimestampHns = -1;
|
||||
|
||||
while (!stopRequested && !encodeFailed) {
|
||||
while (!control.stopRequested && !encodeFailed) {
|
||||
{
|
||||
std::scoped_lock lock(mutex);
|
||||
std::unique_lock lock(mutex);
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load() ||
|
||||
encodeFailed.load() ||
|
||||
(!control.paused.load() && latestFrameTexture);
|
||||
});
|
||||
if (control.stopRequested || encodeFailed) {
|
||||
break;
|
||||
}
|
||||
if (webcamActive) {
|
||||
WebcamFrameSnapshot candidateWebcamFrame;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
|
||||
@@ -564,7 +618,9 @@ int main(int argc, char* argv[]) {
|
||||
firstFrameTimestampHns = sourceTimestampHns;
|
||||
}
|
||||
int64_t frameTimestampHns =
|
||||
std::max<int64_t>(0, sourceTimestampHns - firstFrameTimestampHns);
|
||||
std::max<int64_t>(
|
||||
0,
|
||||
sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns());
|
||||
if (lastEncodedVideoTimestampHns >= 0 &&
|
||||
frameTimestampHns <= lastEncodedVideoTimestampHns) {
|
||||
frameTimestampHns =
|
||||
@@ -588,8 +644,8 @@ int main(int argc, char* argv[]) {
|
||||
frameTimestampHns,
|
||||
!writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (latestFrameTexture) {
|
||||
@@ -598,10 +654,7 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
|
||||
frameIndex += 1;
|
||||
const auto nextDeadline = startedAt +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(static_cast<double>(frameIndex) / config.fps));
|
||||
std::this_thread::sleep_until(nextDeadline);
|
||||
std::this_thread::sleep_for(frameDuration);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -633,8 +686,8 @@ int main(int argc, char* argv[]) {
|
||||
[&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -649,7 +702,7 @@ int main(int argc, char* argv[]) {
|
||||
if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (stopRequested || !audioMixer) {
|
||||
if (control.stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -665,7 +718,7 @@ int main(int argc, char* argv[]) {
|
||||
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (stopRequested || !audioMixer) {
|
||||
if (control.stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -726,16 +779,20 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv));
|
||||
std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) {
|
||||
if (audioMixer) {
|
||||
audioMixer->setPaused(isPaused);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] {
|
||||
return firstFrameWritten.load() || stopRequested.load();
|
||||
const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] {
|
||||
return firstFrameWritten.load() || control.stopRequested.load();
|
||||
});
|
||||
if (!started || !firstFrameWritten) {
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
if (stdinThread.joinable()) {
|
||||
stdinThread.detach();
|
||||
}
|
||||
@@ -761,8 +818,8 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
cv.wait(lock, [&] {
|
||||
return stopRequested.load();
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
stopNativeWindowsRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
||||
},
|
||||
pauseNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-windows-recording");
|
||||
},
|
||||
resumeNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-windows-recording");
|
||||
},
|
||||
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-mac-recording", request);
|
||||
},
|
||||
|
||||
@@ -37,6 +37,21 @@ function findVcVarsAll() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findWindowsSdkUmLibDir() {
|
||||
const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib";
|
||||
if (!fs.existsSync(sdkLibRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(sdkLibRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64"))
|
||||
.filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib")))
|
||||
.sort()
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
@@ -64,6 +79,8 @@ async function runInVsEnv(command) {
|
||||
);
|
||||
}
|
||||
|
||||
const sdkUmLibDir = findWindowsSdkUmLibDir();
|
||||
|
||||
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
|
||||
fs.writeFileSync(
|
||||
cmdPath,
|
||||
@@ -72,9 +89,9 @@ async function runInVsEnv(command) {
|
||||
`call "${vcvarsAll}" x64`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
|
||||
`for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
|
||||
`for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`set "LIB=${COMPAT_LIB_DIR};%LIB%"`,
|
||||
`set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`,
|
||||
command,
|
||||
"exit /b %errorlevel%",
|
||||
"",
|
||||
|
||||
@@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
@@ -71,7 +72,6 @@ import type {
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MAX_ZOOM_SCALE,
|
||||
MIN_ZOOM_SCALE,
|
||||
ROTATION_3D_PRESET_ORDER,
|
||||
@@ -90,37 +90,38 @@ function CustomSpeedInput({
|
||||
onError: () => void;
|
||||
}) {
|
||||
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(value));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const prevValue = useRef(value);
|
||||
if (!isFocused && prevValue.current !== value) {
|
||||
prevValue.current = value;
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const digits = e.target.value.replace(/\D/g, "");
|
||||
if (digits === "") {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
const num = Number(digits);
|
||||
if (num > MAX_PLAYBACK_SPEED) {
|
||||
const result = parseCustomPlaybackSpeedInput(e.target.value);
|
||||
if (result.status === "too-fast") {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
setDraft(digits);
|
||||
if (num >= 1) onChange(num);
|
||||
|
||||
setDraft(result.draft);
|
||||
if (result.status === "valid") {
|
||||
onChange(result.speed);
|
||||
}
|
||||
},
|
||||
[onChange, onError],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (!draft || Number(draft) < 1) {
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
const result = parseCustomPlaybackSpeedInput(draft);
|
||||
if (result.status === "valid") {
|
||||
setDraft(String(result.speed));
|
||||
} else {
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
}, [draft, isPreset, value]);
|
||||
|
||||
@@ -128,8 +129,8 @@ function CustomSpeedInput({
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*[.]?[0-9]*"
|
||||
placeholder="--"
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
|
||||
describe("parseCustomPlaybackSpeedInput", () => {
|
||||
it("accepts decimal playback speeds", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single decimal point while typing", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.23",
|
||||
speed: 1.23,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows sub-1 custom speeds down to the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "0.1",
|
||||
speed: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds below the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({
|
||||
status: "too-slow",
|
||||
draft: "0.09",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts comma decimal input by normalizing to a dot", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds above the editor maximum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({
|
||||
status: "too-fast",
|
||||
draft: "16.1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
clampPlaybackSpeed,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
|
||||
export type CustomPlaybackSpeedInputResult =
|
||||
| { status: "empty"; draft: string }
|
||||
| { status: "too-fast"; draft: string }
|
||||
| { status: "too-slow"; draft: string }
|
||||
| { status: "valid"; draft: string; speed: PlaybackSpeed };
|
||||
|
||||
export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult {
|
||||
const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, "");
|
||||
const [whole = "", ...fractionParts] = decimalDraft.split(".");
|
||||
const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole;
|
||||
|
||||
if (draft === "" || draft === ".") {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
const speed = Number(draft);
|
||||
if (!Number.isFinite(speed)) {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
if (speed > MAX_PLAYBACK_SPEED) {
|
||||
return { status: "too-fast", draft };
|
||||
}
|
||||
|
||||
if (speed < MIN_PLAYBACK_SPEED) {
|
||||
return { status: "too-slow", draft };
|
||||
}
|
||||
|
||||
return { status: "valid", draft, speed: clampPlaybackSpeed(speed) };
|
||||
}
|
||||
@@ -45,8 +45,6 @@ const AUDIO_BITRATE_VOICE = 128_000;
|
||||
const AUDIO_BITRATE_SYSTEM = 192_000;
|
||||
|
||||
const MIC_GAIN_BOOST = 1.4;
|
||||
const WEBCAM_TARGET_WIDTH = 1280;
|
||||
const WEBCAM_TARGET_HEIGHT = 720;
|
||||
const WEBCAM_TARGET_FRAME_RATE = 30;
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
@@ -84,6 +82,7 @@ type RecorderHandle = {
|
||||
type NativeWindowsRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
paused: boolean;
|
||||
webcamRecorder: RecorderHandle | null;
|
||||
};
|
||||
|
||||
@@ -150,9 +149,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const webcamAcquireId = useRef(0);
|
||||
const canPauseRecording =
|
||||
recording &&
|
||||
!nativeWindowsRecording.current &&
|
||||
Boolean(
|
||||
(nativeMacRecording.current && !nativeMacRecording.current.finalizing) ||
|
||||
(nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) ||
|
||||
(nativeMacRecording.current && !nativeMacRecording.current.finalizing) ||
|
||||
(screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"),
|
||||
);
|
||||
|
||||
@@ -268,13 +267,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
video: webcamDeviceId
|
||||
? {
|
||||
deviceId: { exact: webcamDeviceId },
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
}
|
||||
: {
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
},
|
||||
});
|
||||
@@ -798,12 +793,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (availability.reason === "unsupported-os") {
|
||||
return false;
|
||||
}
|
||||
if (availability.reason === "missing-helper") {
|
||||
console.warn("Native Windows capture helper is not available; using browser capture.");
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
availability.reason === "missing-helper"
|
||||
? "Native Windows capture helper is not available."
|
||||
: (availability.error ?? "Native Windows capture is not available."),
|
||||
);
|
||||
throw new Error(availability.error ?? "Native Windows capture is not available.");
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
@@ -858,8 +853,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
enabled: webcamEnabled && !browserWebcamRecorder,
|
||||
deviceId: webcamDeviceId,
|
||||
deviceName: webcamDeviceName,
|
||||
width: WEBCAM_TARGET_WIDTH,
|
||||
height: WEBCAM_TARGET_HEIGHT,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: WEBCAM_TARGET_FRAME_RATE,
|
||||
},
|
||||
cursor: {
|
||||
@@ -881,6 +876,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
nativeWindowsRecording.current = {
|
||||
recordingId: result.recordingId,
|
||||
finalizing: false,
|
||||
paused: false,
|
||||
webcamRecorder: browserWebcamRecorder,
|
||||
};
|
||||
webcamRecorder.current = browserWebcamRecorder;
|
||||
@@ -995,8 +991,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
enabled: webcamEnabled,
|
||||
deviceId: webcamDeviceId,
|
||||
deviceName: webcamDeviceName,
|
||||
width: WEBCAM_TARGET_WIDTH,
|
||||
height: WEBCAM_TARGET_HEIGHT,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: WEBCAM_TARGET_FRAME_RATE,
|
||||
},
|
||||
cursor: {
|
||||
@@ -1409,6 +1405,39 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
|
||||
const togglePaused = () => {
|
||||
const activeNativeWindowsRecording = nativeWindowsRecording.current;
|
||||
if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) {
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeNativeWindowsRecording.paused) {
|
||||
const result = await window.electronAPI.resumeNativeWindowsRecording();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to resume native Windows recording");
|
||||
}
|
||||
activeNativeWindowsRecording.paused = false;
|
||||
segmentStartedAt.current = Date.now();
|
||||
setPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const pausedAtMs = getRecordingDurationMs();
|
||||
const result = await window.electronAPI.pauseNativeWindowsRecording();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to pause native Windows recording");
|
||||
}
|
||||
activeNativeWindowsRecording.paused = true;
|
||||
accumulatedDurationMs.current = pausedAtMs;
|
||||
segmentStartedAt.current = null;
|
||||
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
||||
setPaused(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle native Windows pause state:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to toggle pause state");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeNativeMacRecording = nativeMacRecording.current;
|
||||
if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) {
|
||||
void (async () => {
|
||||
|
||||
@@ -104,8 +104,9 @@
|
||||
"gifButton": "Esporta GIF",
|
||||
"chooseSaveLocation": "Scegli posizione di salvataggio"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Segnala bug",
|
||||
"saveDiagnostics": "Salva dati diagnostici",
|
||||
"starOnGithub": "Metti stella su GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
Reference in New Issue
Block a user