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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user