Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion electron/ipc/export/native-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1944,14 +1944,27 @@ export function sanitizeExportGpuInfo(
return { machineModel, gpus };
}

const GPU_INFO_TIMEOUT_MS = 3000;

/** Captures sanitized hardware and GPU acceleration details for export support reports. */
export async function getExportHardwareInfo(): Promise<ExportHardwareInfo> {
let sanitizedGpuInfo: Pick<ExportHardwareInfo, "machineModel" | "gpus"> = {
machineModel: null,
gpus: [],
};
try {
sanitizedGpuInfo = sanitizeExportGpuInfo(await app.getGPUInfo("complete"));
// app.getGPUInfo("complete") can hang forever (never resolve or reject)
// when the GPU process is crashed, disabled, or blocklisted, which would
// otherwise stall the entire export pipeline at "preparing" with no
// error surfaced. Race it against a timeout so a stuck GPU process can
// never block exporting.
const gpuInfo = await Promise.race([
app.getGPUInfo("complete"),
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error("getGPUInfo timed out")), GPU_INFO_TIMEOUT_MS),
),
]);
sanitizedGpuInfo = sanitizeExportGpuInfo(gpuInfo);
} catch {
// Hardware diagnostics are best effort and must not affect exporting.
}
Expand Down
49 changes: 48 additions & 1 deletion src/components/video-editor/VideoPlayback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const cameraContainerRef = useRef<Container | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const videoReady = usePreviewVideoReady(videoRef, videoPath);
// Bumped to force a full teardown/recreate of the Pixi renderer after a
// lost WebGL/WebGPU context (e.g. Windows reclaiming GPU resources from a
// long-minimized window), since neither backend reliably self-recovers.
const [rendererGeneration, setRendererGeneration] = useState(0);
const contextLostCleanupRef = useRef<(() => void) | null>(null);

const [previewViewportWidth, setPreviewViewportWidth] = useState(640);
const [annotationSceneTransform, setAnnotationSceneTransform] =
Expand Down Expand Up @@ -1743,6 +1748,21 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
appRef.current = app;
container.appendChild(app.canvas);

// A lost WebGL context (common after Windows reclaims GPU resources
// from a window minimized/occluded for a long time) leaves the canvas
// permanently black with no further Pixi errors. Force a full
// teardown/recreate rather than relying on browser-level restore,
// which PixiJS does not reliably resume from.
const canvasEl = app.canvas as unknown as HTMLCanvasElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
console.warn("Preview renderer lost its GPU context; recreating.");
setRendererGeneration((generation) => generation + 1);
};
canvasEl.addEventListener("webglcontextlost", handleContextLost, false);
contextLostCleanupRef.current = () =>
canvasEl.removeEventListener("webglcontextlost", handleContextLost, false);

// Camera container - this will be scaled/positioned for zoom
const cameraContainer = new Container();
cameraContainerRef.current = cameraContainer;
Expand Down Expand Up @@ -1823,6 +1843,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionBlurFilterRef.current?.destroy();
zoomBlurFilterRef.current = null;
motionBlurFilterRef.current = null;
contextLostCleanupRef.current?.();
contextLostCleanupRef.current = null;
destroyPixiApplication(app, "preview renderer");
appRef.current = null;
cameraContainerRef.current = null;
Expand All @@ -1831,7 +1853,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorContainerRef.current = null;
videoSpriteRef.current = null;
};
}, [initializePixiRenderer, onError, syncPreviewMotionBlurQuality]);
}, [initializePixiRenderer, onError, syncPreviewMotionBlurQuality, rendererGeneration]);

// Safety net for GPU context loss that doesn't fire `webglcontextlost`
// (e.g. the WebGPU backend, or a stalled hidden <video> decoder session)
// after the window was minimized/occluded for a long time.
useEffect(() => {
let hiddenAtMs: number | null = document.hidden ? performance.now() : null;
const RECOVERY_THRESHOLD_MS = 60_000;

const handleVisibilityChange = () => {
if (document.hidden) {
hiddenAtMs = performance.now();
return;
}

if (hiddenAtMs !== null && performance.now() - hiddenAtMs >= RECOVERY_THRESHOLD_MS) {
setRendererGeneration((generation) => generation + 1);
}
hiddenAtMs = null;
};

document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);

// biome-ignore lint/correctness/useExhaustiveDependencies: A new media path must reset the persistent video element.
useEffect(() => {
Expand Down
28 changes: 26 additions & 2 deletions src/lib/exporter/modernVideoExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,24 @@ type NativeAudioPlan =
};

const FILTERGRAPH_FALLBACK_AUDIO_SAMPLE_RATE = 48_000;
const RUNTIME_DIAGNOSTICS_IPC_TIMEOUT_MS = 4000;

/** Bounds a diagnostics-only IPC call so a stuck main process can never stall export prep. */
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Timed out")), timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}

function hasNonDefaultSourceTrackSettings(sourceAudioTrackSettings?: SourceAudioTrackSettings) {
if (!sourceAudioTrackSettings) {
Expand Down Expand Up @@ -1015,7 +1033,10 @@ export class ModernVideoExporter {
typeof window !== "undefined" &&
typeof window.electronAPI?.getAppVersion === "function"
) {
diagnostics.appVersion = await window.electronAPI.getAppVersion();
diagnostics.appVersion = await withTimeout(
window.electronAPI.getAppVersion(),
RUNTIME_DIAGNOSTICS_IPC_TIMEOUT_MS,
);
}
} catch {
// Environment diagnostics must never prevent an export attempt.
Expand All @@ -1026,7 +1047,10 @@ export class ModernVideoExporter {
typeof window !== "undefined" &&
typeof window.electronAPI?.getExportHardwareInfo === "function"
) {
const result = await window.electronAPI.getExportHardwareInfo();
const result = await withTimeout(
window.electronAPI.getExportHardwareInfo(),
RUNTIME_DIAGNOSTICS_IPC_TIMEOUT_MS,
);
if (result.success && result.hardware) {
diagnostics.hardware = result.hardware;
}
Expand Down