fix: align native cursor preview and export

This commit is contained in:
EtienneLescot
2026-05-05 11:04:08 +02:00
parent 3d1d4a5ff0
commit 87240a919e
9 changed files with 82 additions and 51 deletions
+30 -1
View File
@@ -651,7 +651,7 @@ export function registerIpcHandlers(
}
});
ipcMain.handle("set-recording-state", async (_, recording: boolean) => {
ipcMain.handle("set-recording-state", async (_, recording: boolean, recordingId?: number) => {
if (recording) {
if (cursorRecordingSession) {
pendingCursorRecordingData = await cursorRecordingSession.stop();
@@ -665,6 +665,8 @@ export function registerIpcHandlers(
platform: process.platform,
sampleIntervalMs: CURSOR_SAMPLE_INTERVAL_MS,
sourceId: getSelectedSourceId(),
startTimeMs:
typeof recordingId === "number" && Number.isFinite(recordingId) ? recordingId : undefined,
});
try {
@@ -824,6 +826,7 @@ export function registerIpcHandlers(
return { success: false, canceled: true };
}
approveFilePath(result.filePaths[0]);
currentProjectPath = null;
return {
success: true,
@@ -863,6 +866,32 @@ export function registerIpcHandlers(
}
});
ipcMain.handle("read-binary-file", async (_, filePath: string) => {
try {
const normalizedPath = await approveReadableVideoPath(filePath);
if (!normalizedPath) {
return {
success: false,
message: "File path is not approved or is not a supported video file",
};
}
const data = await fs.readFile(normalizedPath);
return {
success: true,
data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
path: normalizedPath,
};
} catch (error) {
console.error("Failed to read binary file:", error);
return {
success: false,
message: "Failed to read binary file",
error: String(error),
};
}
});
ipcMain.handle(
"save-project-file",
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
@@ -9,6 +9,7 @@ interface CreateCursorRecordingSessionOptions {
platform: NodeJS.Platform;
sampleIntervalMs: number;
sourceId?: string | null;
startTimeMs?: number;
}
export function createCursorRecordingSession(
@@ -20,6 +21,7 @@ export function createCursorRecordingSession(
maxSamples: options.maxSamples,
sampleIntervalMs: options.sampleIntervalMs,
sourceId: options.sourceId,
startTimeMs: options.startTimeMs,
});
}
@@ -27,5 +29,6 @@ export function createCursorRecordingSession(
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
sampleIntervalMs: options.sampleIntervalMs,
startTimeMs: options.startTimeMs,
});
}
@@ -6,6 +6,7 @@ interface TelemetryRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
sampleIntervalMs: number;
startTimeMs?: number;
}
function clamp(value: number, min: number, max: number) {
@@ -21,7 +22,7 @@ export class TelemetryRecordingSession implements CursorRecordingSession {
async start(): Promise<void> {
this.samples = [];
this.startTimeMs = Date.now();
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.captureSample();
this.interval = setInterval(() => {
this.captureSample();
@@ -41,7 +41,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
this.assets.clear();
this.samples = [];
this.lineBuffer = "";
this.startTimeMs = Date.now();
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.sampleCount = 0;
this.outOfBoundsSampleCount = 0;
@@ -49,4 +49,5 @@ export interface WindowsNativeRecordingSessionOptions {
maxSamples: number;
sampleIntervalMs: number;
sourceId?: string | null;
startTimeMs?: number;
}
-4
View File
@@ -214,10 +214,6 @@ try {
await editorWindow.waitForLoadState("domcontentloaded");
await editorWindow.waitForSelector("video", { state: "attached", timeout: 30_000 });
await editorWindow.waitForSelector("canvas", { state: "attached", timeout: 30_000 });
await editorWindow.waitForSelector('img[aria-hidden="true"]', {
state: "attached",
timeout: 30_000,
});
await editorWindow.setViewportSize({ width: 1280, height: 800 });
await editorWindow.evaluate(async () => {
+27 -37
View File
@@ -27,7 +27,7 @@ import {
} from "@/lib/compositeLayout";
import {
hasNativeCursorRecordingData,
projectNativeCursorToStage,
projectNativeCursorToLocal,
resolveInterpolatedNativeCursorFrame,
resolveNativeCursorRenderAsset,
} from "@/lib/cursor/nativeCursor";
@@ -841,6 +841,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorContainerRef.current = cursorContainer;
cameraContainer.addChild(cursorContainer);
const nativeCursorSprite = new Sprite(Texture.EMPTY);
nativeCursorSprite.visible = false;
nativeCursorSprite.eventMode = "none";
nativeCursorSpriteRef.current = nativeCursorSprite;
cursorContainer.addChild(nativeCursorSprite);
// Cursor overlay - rendered above the masked video
if (cursorOverlayEnabled) {
const cursorOverlay = new PixiCursorOverlay({
@@ -863,6 +869,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorOverlayRef.current.destroy();
cursorOverlayRef.current = null;
}
nativeCursorSpriteRef.current = null;
nativeCursorTextureIdRef.current = null;
if (app && app.renderer) {
app.destroy(true, {
children: true,
@@ -1296,25 +1304,18 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
);
}
// Update native cursor image position at ticker rate (60fps)
const nativeCursorImg = nativeCursorImgRef.current;
if (nativeCursorImg) {
const cameraContainerRc = cameraContainerRef.current;
// Update native cursor sprite in the same PIXI coordinate space as the video.
const nativeCursorSprite = nativeCursorSpriteRef.current;
if (nativeCursorSprite) {
const videoContainerRc = videoContainerRef.current;
if (
hasNativeCursorRecordingRef.current &&
showCursorRef.current &&
cameraContainerRc &&
videoContainerRc
) {
if (hasNativeCursorRecordingRef.current && showCursorRef.current && videoContainerRc) {
const timeMs = currentTimeRef.current; // already in ms
const frame = resolveInterpolatedNativeCursorFrame(
cursorRecordingDataRef.current,
timeMs,
);
if (frame) {
const projectedPoint = projectNativeCursorToStage({
cameraContainer: cameraContainerRc,
const projectedPoint = projectNativeCursorToLocal({
cropRegion: cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 },
maskRect: baseMaskRef.current,
videoContainerPosition: {
@@ -1330,23 +1331,25 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
frame.sample,
);
const scale = Math.max(0, cursorSizeRef.current);
if (nativeCursorImg.dataset.cursorId !== renderAsset.id) {
nativeCursorImg.src = renderAsset.imageDataUrl;
nativeCursorImg.dataset.cursorId = renderAsset.id;
if (nativeCursorTextureIdRef.current !== renderAsset.id) {
nativeCursorSprite.texture = Texture.from(renderAsset.imageDataUrl);
nativeCursorTextureIdRef.current = renderAsset.id;
}
nativeCursorImg.style.left = `${projectedPoint.x - renderAsset.hotspotX * scale}px`;
nativeCursorImg.style.top = `${projectedPoint.y - renderAsset.hotspotY * scale}px`;
nativeCursorImg.style.width = `${renderAsset.width * scale}px`;
nativeCursorImg.style.height = `${renderAsset.height * scale}px`;
nativeCursorImg.style.display = "block";
nativeCursorSprite.position.set(
projectedPoint.x - renderAsset.hotspotX * scale,
projectedPoint.y - renderAsset.hotspotY * scale,
);
nativeCursorSprite.width = renderAsset.width * scale;
nativeCursorSprite.height = renderAsset.height * scale;
nativeCursorSprite.visible = true;
} else {
nativeCursorImg.style.display = "none";
nativeCursorSprite.visible = false;
}
} else {
nativeCursorImg.style.display = "none";
nativeCursorSprite.visible = false;
}
} else {
nativeCursorImg.style.display = "none";
nativeCursorSprite.visible = false;
}
}
@@ -1638,19 +1641,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: "none", pointerEvents: "none" }}
/>
{hasNativeCursorRecording ? (
<img
ref={nativeCursorImgRef}
alt=""
aria-hidden="true"
className="absolute select-none"
style={{
display: "none",
pointerEvents: "none",
userSelect: "none",
}}
/>
) : null}
{(() => {
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (
+3 -3
View File
@@ -719,6 +719,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
recordingId.current = Date.now();
const activeRecordingId = recordingId.current;
screenRecorder.current = createRecorderHandle(stream.current, {
mimeType,
videoBitsPerSecond,
@@ -741,9 +743,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
});
}
recordingId.current = Date.now();
accumulatedDurationMs.current = 0;
segmentStartedAt.current = Date.now();
segmentStartedAt.current = activeRecordingId;
allowAutoFinalize.current = true;
setRecording(true);
setPaused(false);
@@ -752,7 +753,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const activeScreenRecorder = screenRecorder.current;
const activeWebcamRecorder = webcamRecorder.current;
const activeRecordingId = recordingId.current;
if (activeScreenRecorder) {
activeScreenRecorder.recorder.addEventListener(
"stop",
+15 -4
View File
@@ -27,13 +27,16 @@ export interface ActiveNativeCursorFrame {
}
interface ProjectNativeCursorOptions {
cameraContainer: Container;
cropRegion: CropRegion;
maskRect: { x: number; y: number; width: number; height: number };
videoContainerPosition: { x: number; y: number };
sample: CursorRecordingSample;
}
interface ProjectNativeCursorToStageOptions extends ProjectNativeCursorOptions {
cameraContainer: Container;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -264,8 +267,7 @@ export function resolveInterpolatedNativeCursorFrame(
};
}
export function projectNativeCursorToStage({
cameraContainer,
export function projectNativeCursorToLocal({
cropRegion,
maskRect,
videoContainerPosition,
@@ -276,11 +278,20 @@ export function projectNativeCursorToStage({
return null;
}
const localPoint = new Point(
return new Point(
videoContainerPosition.x + maskRect.x + croppedPosition.cx * maskRect.width,
videoContainerPosition.y + maskRect.y + croppedPosition.cy * maskRect.height,
);
}
export function projectNativeCursorToStage({
cameraContainer,
...options
}: ProjectNativeCursorToStageOptions) {
const localPoint = projectNativeCursorToLocal(options);
if (!localPoint) {
return null;
}
return cameraContainer.toGlobal(localPoint);
}