From d12f3980f9cc8841f074fb803ab1e83c6c346ab5 Mon Sep 17 00:00:00 2001 From: Theodor Peifer Date: Tue, 14 Apr 2026 23:39:13 +0200 Subject: [PATCH 1/4] fix: only read back frames from canvas if the OS is linux, work around not necessary for other OS' line win or darwin --- src/lib/exporter/frameRenderer.ts | 6 ++++- src/lib/exporter/videoExporter.ts | 44 ++++++++++++++++++------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 80424b0..7dd93b3 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -187,8 +187,12 @@ export class FrameRenderer { this.compositeCanvas = document.createElement("canvas"); this.compositeCanvas.width = this.config.width; this.compositeCanvas.height = this.config.height; + + // On Linux, getImageData() is called frequently causing frequent CPU readback + const isLinux = (await window.electronAPI.getPlatform()) === "linux"; + this.compositeCtx = this.compositeCanvas.getContext("2d", { - willReadFrequently: false, + willReadFrequently: isLinux, }); if (!this.compositeCtx) { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index dcfcc3e..61ad727 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -111,6 +111,8 @@ export class VideoExporter { this.cancelled = false; this.fatalEncoderError = null; + const platform = await window.electronAPI.getPlatform(); + try { const streamingDecoder = new StreamingVideoDecoder(); this.streamingDecoder = streamingDecoder; @@ -237,25 +239,29 @@ export class VideoExporter { const canvas = renderer.getCanvas(); - // Read raw pixels from the canvas instead of passing - // the canvas directly to VideoFrame. On some Linux - // systems the GPU shared-image path (EGL/Ozone) fails - // silently, producing empty frames. - const canvasCtx = canvas.getContext("2d")!; - const imageData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height); - const exportFrame = new VideoFrame(imageData.data.buffer, { - format: "RGBA", - codedWidth: canvas.width, - codedHeight: canvas.height, - timestamp, - duration: frameDuration, - colorSpace: { - primaries: "bt709", - transfer: "iec61966-2-1", - matrix: "rgb", - fullRange: true, - }, - }); + let exportFrame: VideoFrame; + + // On some Linux systems the GPU shared-image path (EGL/Ozone) fails + // silently, producing empty frames, so we force a CPU readback instead. + if (platform === "linux") { + const canvasCtx = canvas.getContext("2d")!; + const imageData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height); + exportFrame = new VideoFrame(imageData.data.buffer, { + format: "RGBA", + codedWidth: canvas.width, + codedHeight: canvas.height, + timestamp, + duration: frameDuration, + colorSpace: { + primaries: "bt709", + transfer: "iec61966-2-1", + matrix: "rgb", + fullRange: true, + }, + }); + } else { + exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration }); + } while ( this.encoder && From 934f05cc800fa310f7cdbfe485068fa329b9d789 Mon Sep 17 00:00:00 2001 From: Theodor Peifer Date: Wed, 15 Apr 2026 11:22:56 +0200 Subject: [PATCH 2/4] fix: pass platform from video/gifExporter to FrameRenderer, skip readback also for canvas composition for non-linux --- src/lib/exporter/frameRenderer.ts | 12 ++++++++---- src/lib/exporter/gifExporter.ts | 4 ++++ src/lib/exporter/videoExporter.ts | 5 +++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 7dd93b3..9b1cf6d 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -78,6 +78,7 @@ interface FrameRenderConfig { previewWidth?: number; previewHeight?: number; cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[]; + platform: string; } interface AnimationState { @@ -124,9 +125,11 @@ export class FrameRenderer { private smoothedAutoFocus: { cx: number; cy: number } | null = null; private prevAnimationTimeMs: number | null = null; private prevTargetProgress = 0; + private isLinux = false; constructor(config: FrameRenderConfig) { this.config = config; + this.isLinux = config.platform === "linux"; this.animationState = { scale: 1, focusX: DEFAULT_FOCUS.cx, @@ -189,10 +192,8 @@ export class FrameRenderer { this.compositeCanvas.height = this.config.height; // On Linux, getImageData() is called frequently causing frequent CPU readback - const isLinux = (await window.electronAPI.getPlatform()) === "linux"; - this.compositeCtx = this.compositeCanvas.getContext("2d", { - willReadFrequently: isLinux, + willReadFrequently: this.isLinux, }); if (!this.compositeCtx) { @@ -730,7 +731,10 @@ export class FrameRenderer { private compositeWithShadows(webcamFrame?: VideoFrame | null): void { if (!this.compositeCanvas || !this.compositeCtx || !this.app) return; - const videoCanvas = this.readbackVideoCanvas(); + const videoCanvas = this.isLinux + ? this.readbackVideoCanvas() + : (this.app.canvas as HTMLCanvasElement); + const ctx = this.compositeCtx; const w = this.compositeCanvas.width; const h = this.compositeCanvas.height; diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 58ed693..81c540e 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -115,7 +115,10 @@ export class GifExporter { async export(): Promise { let webcamFrameQueue: AsyncVideoFrameQueue | null = null; + try { + const platform = await window.electronAPI.getPlatform(); + this.cleanup(); this.cancelled = false; @@ -153,6 +156,7 @@ export class GifExporter { previewWidth: this.config.previewWidth, previewHeight: this.config.previewHeight, cursorTelemetry: this.config.cursorTelemetry, + platform, }); await this.renderer.initialize(); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 61ad727..13c0696 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -111,9 +111,9 @@ export class VideoExporter { this.cancelled = false; this.fatalEncoderError = null; - const platform = await window.electronAPI.getPlatform(); - try { + const platform = await window.electronAPI.getPlatform(); + const streamingDecoder = new StreamingVideoDecoder(); this.streamingDecoder = streamingDecoder; const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl); @@ -148,6 +148,7 @@ export class VideoExporter { previewWidth: this.config.previewWidth, previewHeight: this.config.previewHeight, cursorTelemetry: this.config.cursorTelemetry, + platform, }); this.renderer = renderer; await renderer.initialize(); From 2f24038cb58c1d7852389f8ebbfda978203f9b97 Mon Sep 17 00:00:00 2001 From: Theodor Peifer Date: Thu, 16 Apr 2026 17:33:05 +0200 Subject: [PATCH 3/4] fix: use existing getPlatform() so the OS based CPU readback check also works in the browser --- src/lib/exporter/gifExporter.ts | 3 ++- src/lib/exporter/videoExporter.ts | 3 ++- src/utils/platformUtils.ts | 11 ++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 81c540e..46ac6a0 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -8,6 +8,7 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; +import { getPlatform } from "@/utils/platformUtils"; import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { FrameRenderer } from "./frameRenderer"; import { StreamingVideoDecoder } from "./streamingDecoder"; @@ -117,7 +118,7 @@ export class GifExporter { let webcamFrameQueue: AsyncVideoFrameQueue | null = null; try { - const platform = await window.electronAPI.getPlatform(); + const platform = await getPlatform(); this.cleanup(); this.cancelled = false; diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 13c0696..7662d93 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -7,6 +7,7 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; +import { getPlatform } from "@/utils/platformUtils"; import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { AudioProcessor } from "./audioEncoder"; import { FrameRenderer } from "./frameRenderer"; @@ -112,7 +113,7 @@ export class VideoExporter { this.fatalEncoderError = null; try { - const platform = await window.electronAPI.getPlatform(); + const platform = await getPlatform(); const streamingDecoder = new StreamingVideoDecoder(); this.streamingDecoder = streamingDecoder; diff --git a/src/utils/platformUtils.ts b/src/utils/platformUtils.ts index 37d5a6d..2fb57e1 100644 --- a/src/utils/platformUtils.ts +++ b/src/utils/platformUtils.ts @@ -3,7 +3,7 @@ let cachedPlatform: string | null = null; /** * Gets the current platform from Electron */ -const getPlatform = async (): Promise => { +export const getPlatform = async (): Promise => { if (cachedPlatform) return cachedPlatform; try { @@ -14,9 +14,14 @@ const getPlatform = async (): Promise => { console.warn("Failed to get platform from Electron, falling back to navigator:", error); // Fallback for development/testing let fallbackPlatform = "win32"; - if (typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform)) { - fallbackPlatform = "darwin"; + if (typeof navigator !== "undefined") { + if (/Mac|iPhone|iPad|iPod/.test(navigator.platform)) { + fallbackPlatform = "darwin"; + } else if (/Linux/.test(navigator.platform)) { + fallbackPlatform = "linux"; + } } + cachedPlatform = fallbackPlatform; return fallbackPlatform; } From 9e4ec790f30fdde2d5fbb00fd0ac2ed1349a9bad Mon Sep 17 00:00:00 2001 From: Theodor Peifer Date: Sat, 18 Apr 2026 11:32:42 +0200 Subject: [PATCH 4/4] chore: fix linting issue --- scripts/i18n-check.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index ca5cb51..699ae9e 100644 --- a/scripts/i18n-check.mjs +++ b/scripts/i18n-check.mjs @@ -11,7 +11,6 @@ import path from "node:path"; const LOCALES_DIR = path.resolve("src/i18n/locales"); const BASE_LOCALE = "en"; -const COMPARE_LOCALES = ["zh-CN", "zh-TW", "es", "tr", "ko-KR"]; function getKeys(obj, prefix = "") { const keys = [];