diff --git a/electron/windows.ts b/electron/windows.ts
index 2f87e6c3d..4cff828e4 100644
--- a/electron/windows.ts
+++ b/electron/windows.ts
@@ -196,6 +196,10 @@ export function createEditorWindow(): BrowserWindow {
win.maximize();
+ if (process.platform !== "darwin") {
+ win.setAutoHideMenuBar(true);
+ }
+
// Show only once painted to avoid a white flash on cold Vite start.
win.once("ready-to-show", () => {
if (!HEADLESS) win.show();
@@ -345,6 +349,10 @@ export function createNotesWindow(): BrowserWindow {
},
});
+ if (process.platform !== "darwin") {
+ win.setAutoHideMenuBar(true);
+ }
+
win.setContentProtection(true);
win.once("ready-to-show", () => {
win.setContentProtection(true);
diff --git a/package-lock.json b/package-lock.json
index b34ad429d..8ab659c16 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1281,6 +1281,72 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/@electron/windows-sign": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
+ "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "cross-dirname": "^0.1.0",
+ "debug": "^4.3.4",
+ "fs-extra": "^11.1.1",
+ "minimist": "^1.2.8",
+ "postject": "^1.0.0-alpha.6"
+ },
+ "bin": {
+ "electron-windows-sign": "bin/electron-windows-sign.js"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/@electron/windows-sign/node_modules/fs-extra": {
+ "version": "11.3.6",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz",
+ "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/@electron/windows-sign/node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/@electron/windows-sign/node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -6241,6 +6307,15 @@
"buffer": "^5.1.0"
}
},
+ "node_modules/cross-dirname": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
+ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -9681,6 +9756,36 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
+ "node_modules/postject": {
+ "version": "1.0.0-alpha.6",
+ "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
+ "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "commander": "^9.4.0"
+ },
+ "bin": {
+ "postject": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/postject/node_modules/commander": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
+ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
diff --git a/package.json b/package.json
index 8d40c9d7e..bf4f3a182 100644
--- a/package.json
+++ b/package.json
@@ -134,5 +134,12 @@
"main": "dist-electron/main.js",
"lint-staged": {
"*.{ts,tsx,js,jsx,mts,cts,json}": "biome check --no-errors-on-unmatched"
+ },
+ "allowScripts": {
+ "electron@41.2.1": true,
+ "esbuild@0.27.7": true,
+ "sharp@0.32.6": true,
+ "protobufjs@6.11.6": true,
+ "electron-winstaller@5.4.0": true
}
}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 96da01f28..9c6a591a8 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1,5 +1,5 @@
import type { Span } from "dnd-timeline";
-import { FolderOpen, Languages, Save, Video } from "lucide-react";
+import { ChevronDown, FolderOpen, Languages, Save, Video } from "lucide-react";
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
@@ -12,6 +12,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -198,6 +206,8 @@ export default function VideoEditor() {
undo,
redo,
resetState,
+ canUndo,
+ canRedo,
} = useEditorHistory(INITIAL_EDITOR_STATE);
const {
@@ -2731,17 +2741,132 @@ export default function VideoEditor() {
style={{ WebkitAppRegion: "drag" } as CSSProperties}
>
-
-
+ {/* Custom Window Menus */}
+
+
+
+
+
+
+
+ {rawT("dialogs.unsavedChanges.newProject" as never) || "New Project"}
+ Ctrl+N
+
+
+
+
+ {rawT("dialogs.unsavedChanges.loadProject" as never) || "Load Project…"}
+
+ Ctrl+O
+
+
+
+ {rawT("dialogs.unsavedChanges.saveProject" as never) || "Save Project…"}
+
+ Ctrl+S
+
+
+
+ {rawT("dialogs.unsavedChanges.saveProjectAs" as never) || "Save Project As…"}
+
+ Ctrl+Shift+S
+
+
+ window.close()}
+ className="hover:bg-red-500/20 focus:bg-red-500/20 focus:text-red-400 text-red-400 cursor-pointer justify-between"
+ >
+ {rawT("common.actions.quit" as never) || "Quit"}
+ Ctrl+Q
+
+
+
+
+
+
+
+
+
+
+ {rawT("common.actions.undo" as never) || "Undo"}
+ Ctrl+Z
+
+
+ {rawT("common.actions.redo" as never) || "Redo"}
+ Ctrl+Y
+
+
+
+
+
+
+
+
+
+ window.location.reload()}
+ className="hover:bg-white/[0.08] focus:bg-white/[0.08] focus:text-white cursor-pointer justify-between"
+ >
+ {rawT("common.actions.reload" as never) || "Reload"}
+ Ctrl+R
+
+
+
+
+
+
+
+
diff --git a/src/components/video-editor/VideoPlayback.test.ts b/src/components/video-editor/VideoPlayback.test.ts
new file mode 100644
index 000000000..165512851
--- /dev/null
+++ b/src/components/video-editor/VideoPlayback.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from "vitest";
+import { createWebGLContextLostHandler } from "@/components/video-editor/VideoPlayback";
+
+describe("createWebGLContextLostHandler", () => {
+ it("calls preventDefault on the lost event so Chromium can attempt restoration", () => {
+ const regenerate = vi.fn();
+ const handler = createWebGLContextLostHandler({ generation: 0, regenerate });
+
+ const event = new Event("webglcontextlost");
+ const preventDefaultSpy = vi.spyOn(event, "preventDefault");
+ handler(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalledOnce();
+ });
+
+ it("triggers the regenerate callback so React can rebuild the Pixi app", () => {
+ const regenerate = vi.fn();
+ const handler = createWebGLContextLostHandler({ generation: 0, regenerate });
+
+ handler(new Event("webglcontextlost"));
+
+ expect(regenerate).toHaveBeenCalledOnce();
+ });
+
+ it("logs the generation captured at handler construction time", () => {
+ const regenerate = vi.fn();
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const handler = createWebGLContextLostHandler({ generation: 7, regenerate });
+
+ handler(new Event("webglcontextlost"));
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ "[VideoPlayback] WebGL context lost, recreating Pixi app",
+ { generation: 7 },
+ );
+ warnSpy.mockRestore();
+ });
+});
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 6550b378b..8ce32cfd2 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -217,6 +217,33 @@ function enableAllPreviewAudioTracks(video: HTMLVideoElement) {
}
}
+/**
+ * Builds a `webglcontextlost` handler for the preview Pixi canvas.
+ *
+ * On Linux/Wayland the preview can lose its WebGL context during heavy GPU
+ * usage from the exporter (e.g. the VideoEncoder's hardware path on
+ * Mesa/EGL, or the Linux-specific CPU readback in `frameRenderer.ts`).
+ * Without recovery the video sprite never reappears — only the wallpaper
+ * (rendered to a 2D canvas) stays visible. See issue #8.
+ *
+ * `preventDefault()` opts in to the restoration attempt so Chromium can
+ * fire `webglcontextrestored`. The caller is expected to bump `regenerate`
+ * so React tears the broken app down and rebuilds from scratch on the
+ * next mount.
+ */
+export function createWebGLContextLostHandler(params: {
+ generation: number;
+ regenerate: () => void;
+}): (event: Event) => void {
+ return (event: Event) => {
+ event.preventDefault();
+ console.warn("[VideoPlayback] WebGL context lost, recreating Pixi app", {
+ generation: params.generation,
+ });
+ params.regenerate();
+ };
+}
+
const VideoPlayback = forwardRef
(
(
{
@@ -292,6 +319,7 @@ const VideoPlayback = forwardRef(
const cameraContainerRef = useRef(null);
const timeUpdateAnimationRef = useRef(null);
const [pixiReady, setPixiReady] = useState(false);
+ const [pixiGeneration, setPixiGeneration] = useState(0);
const [videoReady, setVideoReady] = useState(false);
const [supplementalAudioPath, setSupplementalAudioPath] = useState(null);
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
@@ -1001,6 +1029,10 @@ const VideoPlayback = forwardRef(
let mounted = true;
let app: Application | null = null;
+ // Declared outside the async IIFE so the cleanup function can
+ // detach them after Pixi tears the canvas down.
+ let handleContextLost: ((event: Event) => void) | null = null;
+ let handleContextRestored: (() => void) | null = null;
(async () => {
let cursorOverlayEnabled = true;
@@ -1012,14 +1044,19 @@ const VideoPlayback = forwardRef(
app = new Application();
- await app.init({
- width: container.clientWidth,
- height: container.clientHeight,
- backgroundAlpha: 0,
- antialias: true,
- resolution: window.devicePixelRatio || 1,
- autoDensity: true,
- });
+ try {
+ await app.init({
+ width: container.clientWidth,
+ height: container.clientHeight,
+ backgroundAlpha: 0,
+ antialias: true,
+ resolution: window.devicePixelRatio || 1,
+ autoDensity: true,
+ });
+ } catch (error) {
+ console.error("[VideoPlayback] Pixi init failed:", error);
+ return;
+ }
app.ticker.maxFPS = 60;
@@ -1035,6 +1072,24 @@ const VideoPlayback = forwardRef(
appRef.current = app;
container.appendChild(app.canvas);
+ // Recover from WebGL context loss. On Linux/Wayland the preview
+ // Pixi app can lose its context during heavy GPU usage from the
+ // exporter (e.g. the VideoEncoder's hardware path on Mesa/EGL).
+ // Without recovery the video sprite never reappears — only the
+ // wallpaper (rendered to a 2D canvas) stays visible. See issue
+ // #8. preventDefault() opts in to the restoration attempt so
+ // Chromium can fire webglcontextrestored, then we tear the
+ // broken app down and rebuild from scratch on the next mount.
+ handleContextLost = createWebGLContextLostHandler({
+ generation: pixiGeneration,
+ regenerate: () => setPixiGeneration((g) => g + 1),
+ });
+ handleContextRestored = () => {
+ console.info("[VideoPlayback] WebGL context restored", { generation: pixiGeneration });
+ };
+ app.canvas.addEventListener("webglcontextlost", handleContextLost);
+ app.canvas.addEventListener("webglcontextrestored", handleContextRestored);
+
// Camera container - this will be scaled/positioned for zoom
const cameraContainer = new Container();
cameraContainerRef.current = cameraContainer;
@@ -1070,6 +1125,15 @@ const VideoPlayback = forwardRef(
nativeCursorTextureIdRef.current = null;
nativeCursorImageIdRef.current = null;
if (app && app.renderer) {
+ // Detach the recovery listeners from the canvas before the
+ // app tears it down so we don't leak handlers on a detached
+ // element.
+ if (handleContextLost) {
+ app.canvas.removeEventListener("webglcontextlost", handleContextLost);
+ }
+ if (handleContextRestored) {
+ app.canvas.removeEventListener("webglcontextrestored", handleContextRestored);
+ }
app.destroy(true, {
children: true,
texture: true,
@@ -1081,7 +1145,7 @@ const VideoPlayback = forwardRef(
videoContainerRef.current = null;
videoSpriteRef.current = null;
};
- }, []);
+ }, [pixiGeneration]);
useEffect(() => {
if (!videoPath) {