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) {