mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-14 18:00:49 +00:00
Pause native screen capture across platforms
This commit is contained in:
1 parent
d5f14f9b16
commit
de1074cdff
13 files changed
+378
-42
No files matched your search
Vendored
+10
@@ -45,6 +45,16 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
pauseNativeScreenRecording: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeNativeScreenRecording: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
startFfmpegRecording: (
|
||||
source: any,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
|
||||
|
||||
@@ -75,6 +75,7 @@ let nativeCaptureOutputBuffer = ''
|
||||
let nativeCaptureTargetPath: string | null = null
|
||||
let nativeCaptureStopRequested = false
|
||||
let nativeCaptureMicrophonePath: string | null = null
|
||||
let nativeCapturePaused = false
|
||||
let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null
|
||||
let nativeCursorMonitorOutputBuffer = ''
|
||||
let wgcCaptureProcess: ChildProcessWithoutNullStreams | null = null
|
||||
@@ -82,6 +83,7 @@ let wgcCaptureOutputBuffer = ''
|
||||
let wgcCaptureTargetPath: string | null = null
|
||||
let wgcScreenRecordingActive = false
|
||||
let wgcCaptureStopRequested = false
|
||||
let wgcCapturePaused = false
|
||||
let wgcSystemAudioPath: string | null = null
|
||||
let wgcMicAudioPath: string | null = null
|
||||
let wgcPendingVideoPath: string | null = null
|
||||
@@ -122,6 +124,7 @@ export function killWgcCaptureProcess() {
|
||||
wgcScreenRecordingActive = false
|
||||
nativeScreenRecordingActive = false
|
||||
wgcCaptureStopRequested = false
|
||||
wgcCapturePaused = false
|
||||
wgcSystemAudioPath = null
|
||||
wgcMicAudioPath = null
|
||||
wgcPendingVideoPath = null
|
||||
@@ -2137,6 +2140,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
wgcCaptureOutputBuffer = ''
|
||||
wgcCaptureTargetPath = outputPath
|
||||
wgcCaptureStopRequested = false
|
||||
wgcCapturePaused = false
|
||||
wgcCaptureProcess = spawn(exePath, [JSON.stringify(config)], {
|
||||
cwd: recordingsDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -2162,6 +2166,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
wgcCaptureProcess = null
|
||||
wgcCaptureTargetPath = null
|
||||
wgcCaptureStopRequested = false
|
||||
wgcCapturePaused = false
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to start WGC capture',
|
||||
@@ -2240,6 +2245,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
nativeCaptureTargetPath = outputPath
|
||||
nativeCaptureMicrophonePath = microphoneOutputPath
|
||||
nativeCaptureStopRequested = false
|
||||
nativeCapturePaused = false
|
||||
nativeCaptureProcess = spawn(helperPath, [JSON.stringify(config)], {
|
||||
cwd: recordingsDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -2268,6 +2274,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
nativeCaptureTargetPath = null
|
||||
nativeCaptureMicrophonePath = null
|
||||
nativeCaptureStopRequested = false
|
||||
nativeCapturePaused = false
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to start native ScreenCaptureKit recording',
|
||||
@@ -2294,6 +2301,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
nativeScreenRecordingActive = false
|
||||
wgcCaptureTargetPath = null
|
||||
wgcCaptureStopRequested = false
|
||||
wgcCapturePaused = false
|
||||
|
||||
const finalVideoPath = preferredVideoPath ?? tempVideoPath
|
||||
if (tempVideoPath !== finalVideoPath) {
|
||||
@@ -2310,6 +2318,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
wgcCaptureProcess = null
|
||||
wgcCaptureTargetPath = null
|
||||
wgcCaptureStopRequested = false
|
||||
wgcCapturePaused = false
|
||||
wgcSystemAudioPath = null
|
||||
wgcMicAudioPath = null
|
||||
wgcPendingVideoPath = null
|
||||
@@ -2356,6 +2365,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
nativeCaptureTargetPath = null
|
||||
nativeCaptureMicrophonePath = null
|
||||
nativeCaptureStopRequested = false
|
||||
nativeCapturePaused = false
|
||||
|
||||
const finalVideoPath = preferredVideoPath ?? tempVideoPath
|
||||
if (tempVideoPath !== finalVideoPath) {
|
||||
@@ -2380,6 +2390,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
nativeCaptureTargetPath = null
|
||||
nativeCaptureMicrophonePath = null
|
||||
nativeCaptureStopRequested = false
|
||||
nativeCapturePaused = false
|
||||
|
||||
// Try to recover: if the target file exists on disk, finalize with it
|
||||
if (fallbackPath) {
|
||||
@@ -2400,6 +2411,86 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('pause-native-screen-recording', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
if (!wgcScreenRecordingActive || !wgcCaptureProcess) {
|
||||
return { success: false, message: 'No WGC screen recording is active.' }
|
||||
}
|
||||
|
||||
if (wgcCapturePaused) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
wgcCaptureProcess.stdin.write('pause\n')
|
||||
wgcCapturePaused = true
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Failed to pause WGC capture', error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, message: 'Native screen recording is only available on macOS.' }
|
||||
}
|
||||
|
||||
if (!nativeScreenRecordingActive || !nativeCaptureProcess) {
|
||||
return { success: false, message: 'No native screen recording is active.' }
|
||||
}
|
||||
|
||||
if (nativeCapturePaused) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
nativeCaptureProcess.stdin.write('pause\n')
|
||||
nativeCapturePaused = true
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Failed to pause native screen recording', error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('resume-native-screen-recording', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
if (!wgcScreenRecordingActive || !wgcCaptureProcess) {
|
||||
return { success: false, message: 'No WGC screen recording is active.' }
|
||||
}
|
||||
|
||||
if (!wgcCapturePaused) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
wgcCaptureProcess.stdin.write('resume\n')
|
||||
wgcCapturePaused = false
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Failed to resume WGC capture', error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, message: 'Native screen recording is only available on macOS.' }
|
||||
}
|
||||
|
||||
if (!nativeScreenRecordingActive || !nativeCaptureProcess) {
|
||||
return { success: false, message: 'No native screen recording is active.' }
|
||||
}
|
||||
|
||||
if (!nativeCapturePaused) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
nativeCaptureProcess.stdin.write('resume\n')
|
||||
nativeCapturePaused = false
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Failed to resume native screen recording', error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-system-cursor-assets', async () => {
|
||||
try {
|
||||
return { success: true, cursors: await getSystemCursorAssets() }
|
||||
|
||||
@@ -28,7 +28,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
private var firstPrimaryAudioSampleTime: CMTime?
|
||||
private var firstMicrophoneSampleTime: CMTime?
|
||||
private var lastSampleBuffer: CMSampleBuffer?
|
||||
private var lastVideoPresentationTime: CMTime = .zero
|
||||
private var lastVideoDuration: CMTime = .zero
|
||||
private var isRecording = false
|
||||
private var isPaused = false
|
||||
private var pauseStartedHostTime: CMTime?
|
||||
private var pendingResumeAdjustment = false
|
||||
private var accumulatedPausedDuration: CMTime = .zero
|
||||
private var sessionStarted = false
|
||||
private var frameCount = 0
|
||||
private var outputURL: URL?
|
||||
@@ -230,8 +236,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
assetWriter.startSession(atSourceTime: .zero)
|
||||
sessionStarted = true
|
||||
isRecording = true
|
||||
isPaused = false
|
||||
pauseStartedHostTime = nil
|
||||
pendingResumeAdjustment = false
|
||||
accumulatedPausedDuration = .zero
|
||||
frameCount = 0
|
||||
firstSampleTime = .zero
|
||||
lastVideoPresentationTime = .zero
|
||||
lastVideoDuration = .zero
|
||||
startWindowValidationIfNeeded()
|
||||
print("Recording started")
|
||||
fflush(stdout)
|
||||
@@ -245,8 +257,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
return try await finishCapture()
|
||||
}
|
||||
|
||||
func pauseCapture() {
|
||||
guard isRecording, !isPaused else { return }
|
||||
isPaused = true
|
||||
pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock())
|
||||
pendingResumeAdjustment = false
|
||||
}
|
||||
|
||||
func resumeCapture() {
|
||||
guard isRecording, isPaused else { return }
|
||||
isPaused = false
|
||||
pendingResumeAdjustment = true
|
||||
}
|
||||
|
||||
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
|
||||
guard sessionStarted, sampleBuffer.isValid, isRecording else { return }
|
||||
guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }
|
||||
|
||||
if outputType == .screen {
|
||||
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
|
||||
@@ -263,11 +289,12 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
firstSampleTime = sampleBuffer.presentationTimeStamp
|
||||
}
|
||||
|
||||
let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime
|
||||
lastSampleBuffer = sampleBuffer
|
||||
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
|
||||
if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
|
||||
videoInput.append(retimedSampleBuffer)
|
||||
lastVideoPresentationTime = presentationTime
|
||||
lastVideoDuration = sampleBuffer.duration
|
||||
frameCount += 1
|
||||
}
|
||||
return
|
||||
@@ -275,15 +302,15 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
|
||||
if outputType == .audio {
|
||||
guard primaryAudioSource == .system, let audioInput else { return }
|
||||
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime)
|
||||
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime, presentationTime: presentationTime)
|
||||
return
|
||||
}
|
||||
|
||||
if outputType.rawValue == microphoneOutputTypeRawValue {
|
||||
if writesMicrophoneToSeparateTrack, let microphoneOnlyInput {
|
||||
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime)
|
||||
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime)
|
||||
} else if primaryAudioSource == .microphone, let audioInput {
|
||||
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime)
|
||||
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime, presentationTime: presentationTime)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -312,7 +339,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
stream = nil
|
||||
|
||||
if let originalBuffer = lastSampleBuffer, let videoInput = videoInput {
|
||||
let additionalTime = CMTime(seconds: ProcessInfo.processInfo.systemUptime, preferredTimescale: 600) - firstSampleTime
|
||||
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]) {
|
||||
videoInput.append(additionalSampleBuffer)
|
||||
@@ -340,7 +367,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
firstPrimaryAudioSampleTime = nil
|
||||
firstMicrophoneSampleTime = nil
|
||||
lastSampleBuffer = nil
|
||||
lastVideoPresentationTime = .zero
|
||||
lastVideoDuration = .zero
|
||||
frameCount = 0
|
||||
isPaused = false
|
||||
pauseStartedHostTime = nil
|
||||
pendingResumeAdjustment = false
|
||||
accumulatedPausedDuration = .zero
|
||||
capturesSystemAudio = false
|
||||
capturesMicrophone = false
|
||||
writesMicrophoneToSeparateTrack = false
|
||||
@@ -348,16 +381,53 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
return path
|
||||
}
|
||||
|
||||
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?) {
|
||||
private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? {
|
||||
if isPaused {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sampleTime = sampleBuffer.presentationTimeStamp
|
||||
if pendingResumeAdjustment, let pauseStartedHostTime {
|
||||
let pauseGap = sampleTime - pauseStartedHostTime
|
||||
if pauseGap > .zero {
|
||||
accumulatedPausedDuration = accumulatedPausedDuration + pauseGap
|
||||
}
|
||||
self.pauseStartedHostTime = nil
|
||||
pendingResumeAdjustment = false
|
||||
}
|
||||
|
||||
if outputType == .screen {
|
||||
if firstSampleTime == .zero {
|
||||
firstSampleTime = sampleTime
|
||||
}
|
||||
return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration)
|
||||
}
|
||||
|
||||
return sampleTime - accumulatedPausedDuration
|
||||
}
|
||||
|
||||
private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime {
|
||||
if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero {
|
||||
return sampleBuffer.duration
|
||||
}
|
||||
|
||||
if lastVideoDuration.isValid && lastVideoDuration > .zero {
|
||||
return lastVideoDuration
|
||||
}
|
||||
|
||||
return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS))
|
||||
}
|
||||
|
||||
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?, presentationTime: CMTime) {
|
||||
guard input.isReadyForMoreMediaData else { return }
|
||||
|
||||
if firstSampleTime == nil {
|
||||
firstSampleTime = sampleBuffer.presentationTimeStamp
|
||||
firstSampleTime = presentationTime
|
||||
}
|
||||
|
||||
guard let firstSampleTime else { return }
|
||||
let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime
|
||||
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
|
||||
let relativePresentationTime = max(.zero, presentationTime - firstSampleTime)
|
||||
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: relativePresentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
|
||||
if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
|
||||
input.append(retimedSampleBuffer)
|
||||
}
|
||||
@@ -457,6 +527,18 @@ final class RecorderService {
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
queue.async {
|
||||
self.recorder.pauseCapture()
|
||||
}
|
||||
}
|
||||
|
||||
func resume() {
|
||||
queue.async {
|
||||
self.recorder.resumeCapture()
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilFinished() {
|
||||
completionGroup.wait()
|
||||
}
|
||||
@@ -478,6 +560,16 @@ service.start(configJSON: CommandLine.arguments[1])
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
while let input = readLine(strippingNewline: true)?.lowercased() {
|
||||
if input == "pause" {
|
||||
service.pause()
|
||||
continue
|
||||
}
|
||||
|
||||
if input == "resume" {
|
||||
service.resume()
|
||||
continue
|
||||
}
|
||||
|
||||
if input == "stop" {
|
||||
service.stop()
|
||||
break
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,8 +12,14 @@
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <chrono>
|
||||
|
||||
static std::atomic<bool> g_stopRequested{false};
|
||||
static std::atomic<bool> g_pauseRequested{false};
|
||||
static std::atomic<bool> g_resumePending{false};
|
||||
static std::atomic<int64_t> g_lastFrameTimestampHns{0};
|
||||
static std::atomic<int64_t> g_pauseStartTimestampHns{0};
|
||||
static std::atomic<int64_t> g_accumulatedPausedHns{0};
|
||||
static std::mutex g_stopMutex;
|
||||
static std::condition_variable g_stopCv;
|
||||
|
||||
@@ -140,6 +146,18 @@ static void stdinListenerThread() {
|
||||
line.pop_back();
|
||||
}
|
||||
|
||||
if (line == "pause") {
|
||||
g_pauseRequested = true;
|
||||
g_pauseStartTimestampHns = g_lastFrameTimestampHns.load();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line == "resume") {
|
||||
g_pauseRequested = false;
|
||||
g_resumePending = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line == "stop") {
|
||||
g_stopRequested = true;
|
||||
g_stopCv.notify_all();
|
||||
@@ -209,8 +227,25 @@ int main(int argc, char* argv[]) {
|
||||
// Set up frame callback
|
||||
std::atomic<int64_t> frameCount{0};
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
g_lastFrameTimestampHns = timestampHns;
|
||||
if (g_stopRequested) return;
|
||||
if (encoder.writeFrame(texture, timestampHns)) {
|
||||
|
||||
if (g_pauseRequested) return;
|
||||
|
||||
int64_t adjustedTimestampHns = timestampHns;
|
||||
if (g_resumePending.exchange(false)) {
|
||||
const int64_t pauseStart = g_pauseStartTimestampHns.load();
|
||||
if (pauseStart > 0 && timestampHns > pauseStart) {
|
||||
g_accumulatedPausedHns += (timestampHns - pauseStart);
|
||||
}
|
||||
}
|
||||
|
||||
adjustedTimestampHns -= g_accumulatedPausedHns.load();
|
||||
if (adjustedTimestampHns < 0) {
|
||||
adjustedTimestampHns = 0;
|
||||
}
|
||||
|
||||
if (encoder.writeFrame(texture, adjustedTimestampHns)) {
|
||||
frameCount++;
|
||||
}
|
||||
});
|
||||
@@ -258,9 +293,17 @@ int main(int argc, char* argv[]) {
|
||||
std::cout.flush();
|
||||
|
||||
// Wait for stop signal
|
||||
{
|
||||
while (!g_stopRequested) {
|
||||
if (g_pauseRequested) {
|
||||
if (audioActive) loopback.pause();
|
||||
if (micActive) micCapture.pause();
|
||||
} else {
|
||||
if (audioActive) loopback.resume();
|
||||
if (micActive) micCapture.resume();
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(g_stopMutex);
|
||||
g_stopCv.wait(lock, [] { return g_stopRequested.load(); });
|
||||
g_stopCv.wait_for(lock, std::chrono::milliseconds(20), [] { return g_stopRequested.load(); });
|
||||
}
|
||||
|
||||
// Stop capture and finalize
|
||||
|
||||
@@ -125,19 +125,61 @@ bool WasapiCapture::initializeCommon() {
|
||||
}
|
||||
|
||||
bool WasapiCapture::start() {
|
||||
if (capturing_) return true;
|
||||
|
||||
outputFile_ = CreateFileA(
|
||||
outputPath_.c_str(), GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
|
||||
if (outputFile_ == INVALID_HANDLE_VALUE) {
|
||||
std::cerr << "ERROR: Cannot create audio output file" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
totalDataBytes_ = 0;
|
||||
writeWavHeader(outputFile_, 0);
|
||||
|
||||
HRESULT hr = audioClient_->Start();
|
||||
if (FAILED(hr)) return false;
|
||||
if (FAILED(hr)) {
|
||||
CloseHandle(outputFile_);
|
||||
outputFile_ = INVALID_HANDLE_VALUE;
|
||||
return false;
|
||||
}
|
||||
|
||||
capturing_ = true;
|
||||
paused_ = false;
|
||||
thread_ = std::thread(&WasapiCapture::captureThread, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WasapiCapture::pause() {
|
||||
if (!capturing_ || paused_) return true;
|
||||
paused_ = true;
|
||||
return audioClient_ ? SUCCEEDED(audioClient_->Stop()) : false;
|
||||
}
|
||||
|
||||
bool WasapiCapture::resume() {
|
||||
if (!capturing_ || !paused_) return true;
|
||||
HRESULT hr = audioClient_ ? audioClient_->Start() : E_FAIL;
|
||||
if (FAILED(hr)) return false;
|
||||
paused_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WasapiCapture::stop() {
|
||||
if (!capturing_) return;
|
||||
capturing_ = false;
|
||||
if (thread_.joinable()) thread_.join();
|
||||
if (audioClient_) audioClient_->Stop();
|
||||
|
||||
if (outputFile_ != INVALID_HANDLE_VALUE) {
|
||||
SetFilePointer(outputFile_, 0, nullptr, FILE_BEGIN);
|
||||
writeWavHeader(outputFile_, totalDataBytes_);
|
||||
CloseHandle(outputFile_);
|
||||
outputFile_ = INVALID_HANDLE_VALUE;
|
||||
}
|
||||
|
||||
paused_ = false;
|
||||
}
|
||||
|
||||
static int16_t floatToInt16(float v) {
|
||||
@@ -179,22 +221,16 @@ void WasapiCapture::captureThread() {
|
||||
reinterpret_cast<WAVEFORMATEXTENSIBLE*>(mixFormat_)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT);
|
||||
|
||||
std::vector<int16_t> pcmBuffer;
|
||||
HANDLE tmpFile = CreateFileA(
|
||||
outputPath_.c_str(), GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
|
||||
if (tmpFile == INVALID_HANDLE_VALUE) {
|
||||
std::cerr << "ERROR: Cannot create audio output file" << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
writeWavHeader(tmpFile, 0);
|
||||
DWORD totalDataBytes = 0;
|
||||
|
||||
DWORD sleepMs = static_cast<DWORD>((static_cast<double>(bufferFrameCount_) / mixFormat_->nSamplesPerSec) * 500.0);
|
||||
if (sleepMs < 5) sleepMs = 5;
|
||||
|
||||
while (capturing_) {
|
||||
if (paused_) {
|
||||
Sleep(10);
|
||||
continue;
|
||||
}
|
||||
|
||||
Sleep(sleepMs);
|
||||
|
||||
UINT32 packetLength = 0;
|
||||
@@ -228,15 +264,11 @@ void WasapiCapture::captureThread() {
|
||||
|
||||
DWORD bytesToWrite = totalSamples * sizeof(int16_t);
|
||||
DWORD written;
|
||||
WriteFile(tmpFile, pcmBuffer.data(), bytesToWrite, &written, nullptr);
|
||||
totalDataBytes += written;
|
||||
WriteFile(outputFile_, pcmBuffer.data(), bytesToWrite, &written, nullptr);
|
||||
totalDataBytes_ += written;
|
||||
|
||||
hr = captureClient_->GetNextPacketSize(&packetLength);
|
||||
if (FAILED(hr)) break;
|
||||
}
|
||||
}
|
||||
|
||||
SetFilePointer(tmpFile, 0, nullptr, FILE_BEGIN);
|
||||
writeWavHeader(tmpFile, totalDataBytes);
|
||||
CloseHandle(tmpFile);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public:
|
||||
bool initializeLoopback(const std::string& outputPath);
|
||||
bool initializeMic(const std::string& outputPath, const std::string& deviceName = "");
|
||||
bool start();
|
||||
bool pause();
|
||||
bool resume();
|
||||
void stop();
|
||||
|
||||
private:
|
||||
@@ -27,6 +29,9 @@ private:
|
||||
std::string outputPath_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> capturing_{false};
|
||||
std::atomic<bool> paused_{false};
|
||||
HANDLE outputFile_ = INVALID_HANDLE_VALUE;
|
||||
DWORD totalDataBytes_ = 0;
|
||||
|
||||
IMMDeviceEnumerator* enumerator_ = nullptr;
|
||||
IMMDevice* device_ = nullptr;
|
||||
|
||||
@@ -51,6 +51,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
stopNativeScreenRecording: () => {
|
||||
return ipcRenderer.invoke("stop-native-screen-recording");
|
||||
},
|
||||
pauseNativeScreenRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-screen-recording");
|
||||
},
|
||||
resumeNativeScreenRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-screen-recording");
|
||||
},
|
||||
startFfmpegRecording: (source: any) => {
|
||||
return ipcRenderer.invoke("start-ffmpeg-recording", source);
|
||||
},
|
||||
|
||||
@@ -97,6 +97,36 @@ function resumeRecording(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pauseNativeRecording(
|
||||
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null,
|
||||
result: { success: boolean } = { success: true },
|
||||
): Promise<boolean> {
|
||||
if (!result.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (webcamRecorder?.state === "recording") {
|
||||
webcamRecorder.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resumeNativeRecording(
|
||||
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null,
|
||||
result: { success: boolean } = { success: true },
|
||||
): Promise<boolean> {
|
||||
if (!result.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (webcamRecorder?.state === "paused") {
|
||||
webcamRecorder.resume();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelRecording(
|
||||
recorder: ReturnType<typeof createMockMediaRecorder>,
|
||||
isNativeRecording: boolean,
|
||||
@@ -413,18 +443,30 @@ describe("useScreenRecorder state machine", () => {
|
||||
expect(webcam.state).toBe("inactive");
|
||||
});
|
||||
|
||||
it("native recording: webcam pauses/resumes while screen keeps capturing", () => {
|
||||
it("native recording pauses webcam only after native pause succeeds", async () => {
|
||||
const webcam = createMockMediaRecorder("recording");
|
||||
|
||||
pauseRecording(recorder, true, false, true, webcam);
|
||||
const pausedResult = await pauseNativeRecording(webcam);
|
||||
expect(pausedResult).toBe(true);
|
||||
expect(webcam.state).toBe("paused");
|
||||
expect(recorder.pause).not.toHaveBeenCalled();
|
||||
|
||||
resumeRecording(recorder, true, true, true, webcam);
|
||||
const resumedResult = await resumeNativeRecording(webcam);
|
||||
expect(resumedResult).toBe(true);
|
||||
expect(webcam.state).toBe("recording");
|
||||
expect(recorder.resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("native recording leaves webcam state alone when native pause fails", async () => {
|
||||
const webcam = createMockMediaRecorder("recording");
|
||||
|
||||
const pausedResult = await pauseNativeRecording(webcam, { success: false });
|
||||
|
||||
expect(pausedResult).toBe(false);
|
||||
expect(webcam.state).toBe("recording");
|
||||
expect(webcam.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancel discards both screen and webcam recordings", () => {
|
||||
const webcam = createMockMediaRecorder("recording");
|
||||
const chunks = { current: [new Blob(["screen"])] };
|
||||
|
||||
@@ -751,11 +751,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const pauseRecording = useCallback(() => {
|
||||
if (!recording || paused) return;
|
||||
if (nativeScreenRecording.current) {
|
||||
// Native captures cannot truly pause, but we pause the timer/UI and webcam
|
||||
if (webcamRecorder.current?.state === "recording") {
|
||||
webcamRecorder.current.pause();
|
||||
}
|
||||
setPaused(true);
|
||||
void (async () => {
|
||||
const result = await window.electronAPI.pauseNativeScreenRecording();
|
||||
if (!result.success) {
|
||||
console.error("Failed to pause native screen recording:", result.error ?? result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (webcamRecorder.current?.state === "recording") {
|
||||
webcamRecorder.current.pause();
|
||||
}
|
||||
setPaused(true);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (mediaRecorder.current?.state === "recording") {
|
||||
@@ -770,10 +777,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const resumeRecording = useCallback(() => {
|
||||
if (!recording || !paused) return;
|
||||
if (nativeScreenRecording.current) {
|
||||
if (webcamRecorder.current?.state === "paused") {
|
||||
webcamRecorder.current.resume();
|
||||
}
|
||||
setPaused(false);
|
||||
void (async () => {
|
||||
const result = await window.electronAPI.resumeNativeScreenRecording();
|
||||
if (!result.success) {
|
||||
console.error("Failed to resume native screen recording:", result.error ?? result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (webcamRecorder.current?.state === "paused") {
|
||||
webcamRecorder.current.resume();
|
||||
}
|
||||
setPaused(false);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (mediaRecorder.current?.state === "paused") {
|
||||
|
||||
Reference in new issue
Block a user