fix screen recording, optimize exporting pipeline

This commit is contained in:
Siddharth
2025-11-22 23:40:39 -07:00
parent 55a373c7ef
commit 0e0f5003ff
5 changed files with 48 additions and 75 deletions
+9 -2
View File
@@ -184,8 +184,15 @@ export default function VideoEditor() {
videoPlaybackRef.current?.pause();
}
const width = 1920;
const height = 1080;
// Get actual video dimensions to match recording resolution
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error('Video not ready');
return;
}
const width = video.videoWidth || 1920;
const height = video.videoHeight || 1080;
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = width * height;
+9 -6
View File
@@ -55,17 +55,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
await window.electronAPI.startMouseTracking();
// Enable hardware acceleration and set optimal resolution/framerate constraints
// Capture screen at source resolution without constraints
const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: selectedSource.id,
minWidth: 1920,
minHeight: 1080,
maxWidth: 3840,
maxHeight: 2160,
frameRate: { ideal: 60, max: 60 }
},
},
@@ -75,7 +71,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
throw new Error("Media stream is not available.");
}
const videoTrack = stream.current.getVideoTracks()[0];
const { width = 1920, height = 1080 } = videoTrack.getSettings();
let { width = 1920, height = 1080 } = videoTrack.getSettings();
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
width = Math.floor(width / 2) * 2;
height = Math.floor(height / 2) * 2;
console.log(`Recording at ${width}x${height}`);
const totalPixels = width * height;
// Use visually lossless bitrates optimized for quality and file size balance
let bitrate = 30_000_000;
+10 -8
View File
@@ -69,15 +69,15 @@ export class FrameRenderer {
console.warn('[FrameRenderer] colorSpace not supported on this platform:', error);
}
// Initialize PixiJS app with transparent background (background rendered separately)
// Initialize PixiJS with optimized settings for export performance
this.app = new PIXI.Application();
await this.app.init({
canvas,
width: this.config.width,
height: this.config.height,
backgroundAlpha: 0,
antialias: true,
resolution: 2,
antialias: false,
resolution: 1,
autoDensity: true,
});
@@ -249,15 +249,17 @@ export class FrameRenderer {
this.currentVideoTime = timestamp / 1000000;
// Create or update video sprite from VideoFrame
// Create or update video sprite from VideoFrame (optimized to reuse sprite)
if (!this.videoSprite) {
const texture = PIXI.Texture.from(videoFrame as any);
this.videoSprite = new PIXI.Sprite(texture);
this.videoContainer.addChild(this.videoSprite);
} else {
// Update texture with new frame
const texture = PIXI.Texture.from(videoFrame as any);
this.videoSprite.texture = texture;
// Destroy old texture to avoid memory leaks, then create new one
const oldTexture = this.videoSprite.texture;
const newTexture = PIXI.Texture.from(videoFrame as any);
this.videoSprite.texture = newTexture;
oldTexture.destroy(true);
}
// Apply layout
@@ -442,7 +444,7 @@ export class FrameRenderer {
console.warn('[FrameRenderer] No background sprite found during compositing!');
}
// Step 2: Draw video layer with shadows on top of background
// Draw video layer with shadows on top of background (using CSS filter for accuracy)
if (this.config.showShadow && this.shadowCanvas && this.shadowCtx) {
const shadowCtx = this.shadowCtx;
shadowCtx.clearRect(0, 0, w, h);
-29
View File
@@ -7,7 +7,6 @@ export interface DecodedVideoInfo {
}
export class VideoFileDecoder {
private decoder: VideoDecoder | null = null;
private info: DecodedVideoInfo | null = null;
private videoElement: HTMLVideoElement | null = null;
@@ -44,27 +43,6 @@ export class VideoFileDecoder {
return this.videoElement;
}
/**
* Seek to a specific time and wait for the frame to be ready
*/
async seekToTime(timeInSeconds: number): Promise<void> {
if (!this.videoElement) {
throw new Error('Video not loaded');
}
return new Promise((resolve) => {
const video = this.videoElement!;
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked);
resolve();
};
video.addEventListener('seeked', onSeeked);
video.currentTime = timeInSeconds;
});
}
getInfo(): DecodedVideoInfo | null {
return this.info;
}
@@ -75,12 +53,5 @@ export class VideoFileDecoder {
this.videoElement.src = '';
this.videoElement = null;
}
if (this.decoder) {
if (this.decoder.state !== 'closed') {
this.decoder.close();
}
this.decoder = null;
}
}
}
+20 -30
View File
@@ -23,7 +23,8 @@ export class VideoExporter {
private cancelled = false;
private encodedChunks: EncodedVideoChunk[] = [];
private encodeQueue = 0;
private readonly MAX_ENCODE_QUEUE = 60;
// Increased queue size for better throughput with hardware encoding
private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined;
@@ -68,38 +69,25 @@ export class VideoExporter {
throw new Error('Video element not available');
}
// Process frames with optimized seeking
// Process frames with optimized seeking (no unnecessary timeouts)
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
const timeStep = 1 / this.config.frameRate;
// Pre-load first frame
videoElement.currentTime = 0;
await new Promise(resolve => {
const onSeeked = () => {
videoElement.removeEventListener('seeked', onSeeked);
resolve(null);
};
videoElement.addEventListener('seeked', onSeeked);
});
while (frameIndex < totalFrames && !this.cancelled) {
const timestamp = frameIndex * frameDuration;
const videoTime = frameIndex * timeStep;
// Seek to frame (only seek if not already there)
if (Math.abs(videoElement.currentTime - videoTime) > 0.001) {
videoElement.currentTime = videoTime;
await Promise.race([
new Promise(resolve => {
const onSeeked = () => {
videoElement.removeEventListener('seeked', onSeeked);
// Wait for video to render the frame
videoElement.requestVideoFrameCallback(() => resolve(null));
};
videoElement.addEventListener('seeked', onSeeked, { once: true });
}),
new Promise(resolve => setTimeout(resolve, 200)) // higher this number, slower the export, but better capture/ no frame drops
]);
// Seek if needed or wait for first frame to be ready
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
if (needsSeek || frameIndex === 0) {
if (needsSeek) {
videoElement.currentTime = videoTime;
}
// Wait for video frame to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
});
}
// Create a VideoFrame from the video element (on GPU!)
@@ -112,16 +100,17 @@ export class VideoExporter {
videoFrame.close();
// Wait for encoder queue to have space (yield immediately instead of 1ms timeout)
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 1));
await new Promise(resolve => setTimeout(resolve, 0));
}
if (this.cancelled) break;
const canvas = this.renderer!.getCanvas();
// @ts-ignore - TypeScript definitions may not include all VideoFrameInit properties
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
@@ -141,7 +130,8 @@ export class VideoExporter {
frameIndex++;
if (this.config.onProgress) {
// Batch progress updates to reduce callback overhead (every 5 frames)
if (frameIndex % 5 === 0 && this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,