From b305917ac1d4e1c8161804889deb452b3c1f07b4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 25 Apr 2026 18:14:48 +0200 Subject: [PATCH] fix: surface degraded GPU renderer notices --- Cargo.lock | 2 +- apps/web/package.json | 2 +- apps/web/src/app/editor/[project_id]/page.tsx | 31 +---- .../components/editor/editor-notice-bar.tsx | 49 +++++++ .../components/providers/editor-provider.tsx | 39 +++++- apps/web/src/core/index.ts | 3 + .../core/managers/editor-notices-manager.ts | 126 ++++++++++++++++++ .../web/src/core/managers/renderer-manager.ts | 16 +-- apps/web/src/editor/use-editor.ts | 1 + apps/web/src/preview/components/index.tsx | 41 +++--- .../web/src/services/renderer/gpu-renderer.ts | 37 ++++- rust/crates/compositor/src/compositor.rs | 70 +++++----- rust/crates/effects/src/pipeline.rs | 14 +- rust/crates/gpu/src/context.rs | 69 +++++++++- rust/crates/masks/src/feather.rs | 22 ++- rust/crates/masks/src/sdf.rs | 14 +- rust/wasm/Cargo.toml | 2 +- rust/wasm/src/gpu.rs | 65 +++++---- rust/wasm/src/panic.rs | 25 ++++ rust/wasm/src/wasm.rs | 2 + 20 files changed, 452 insertions(+), 178 deletions(-) create mode 100644 apps/web/src/components/editor/editor-notice-bar.tsx create mode 100644 apps/web/src/core/managers/editor-notices-manager.ts create mode 100644 rust/wasm/src/panic.rs diff --git a/Cargo.lock b/Cargo.lock index f3bd3c00..04c00a6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3820,7 +3820,7 @@ dependencies = [ [[package]] name = "opencut-wasm" -version = "0.2.9" +version = "0.2.10" dependencies = [ "bridge", "compositor", diff --git a/apps/web/package.json b/apps/web/package.json index 0034ebad..bbc3ad0a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,7 +52,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.2.9", + "opencut-wasm": "^0.2.10", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index b3d40a37..8b586ad3 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -11,17 +11,15 @@ import { PropertiesPanel } from "@/components/editor/panels/properties"; import { Timeline } from "@/timeline/components"; import { PreviewPanel } from "@/preview/components"; import { EditorHeader } from "@/components/editor/editor-header"; +import { EditorNoticeBar } from "@/components/editor/editor-notice-bar"; import { EditorProvider } from "@/components/providers/editor-provider"; import { Onboarding } from "@/components/editor/onboarding"; import { MigrationDialog } from "@/project/components/migration-dialog"; import { usePanelStore } from "@/editor/panel-store"; +import { useEditor } from "@/editor/use-editor"; import { usePasteMedia } from "@/media/use-paste-media"; import { MobileGate } from "@/components/editor/mobile-gate"; -import { useMemo, useState } from "react"; -import { useEditor } from "@/editor/use-editor"; -import { Cancel01Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Button } from "@/components/ui/button"; +import { useMemo } from "react"; import { ChangelogNotification } from "@/changelog/components/changelog-notification"; import { createPreviewOverlayControl, @@ -43,7 +41,7 @@ export default function Editor() {
- +
@@ -57,27 +55,6 @@ export default function Editor() { ); } -function DegradedRendererBanner() { - const isDegraded = useEditor((e) => e.renderer.isDegraded); - const [dismissed, setDismissed] = useState(false); - if (!isDegraded || dismissed) return null; - - return ( -
- For the best experience, open OpenCut in Chrome. - -
- ); -} - function EditorLayout() { usePasteMedia(); const { panels, setPanel } = usePanelStore(); diff --git a/apps/web/src/components/editor/editor-notice-bar.tsx b/apps/web/src/components/editor/editor-notice-bar.tsx new file mode 100644 index 00000000..8c231faa --- /dev/null +++ b/apps/web/src/components/editor/editor-notice-bar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useEditor } from "@/editor/use-editor"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; + +const toneClassName = { + info: "bg-accent text-muted-foreground", + warning: "bg-caution/10 text-caution", + error: "bg-destructive/10 text-destructive", +} as const; + +export function EditorNoticeBar() { + const editor = useEditor(); + const notices = useEditor((e) => e.notices.getNotices()); + + if (notices.length === 0) { + return null; + } + + return ( +
+ {notices.map((notice) => ( +
+ {notice.message} + {notice.dismissible ? ( + + ) : null} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index fdc84af0..e93c678d 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -12,8 +12,37 @@ import { useEditorActions } from "@/actions/use-editor-actions"; import { loadFontAtlas } from "@/fonts/google-fonts"; import { initializeGpuRenderer, - isGpuAvailable, + type GpuRendererInitializationResult, } from "@/services/renderer/gpu-renderer"; +import type { EditorNoticeInput } from "@/core/managers/editor-notices-manager"; + +const RENDERER_NOTICES: Record< + GpuRendererInitializationResult["kind"], + EditorNoticeInput | null +> = { + ready: null, + "software-fallback-adapter": { + id: "software-fallback-adapter", + tone: "warning", + message: + "This browser is using a software-only graphics adapter. Preview rendering may fail or appear blank. If that happens, switch to a hardware-accelerated browser.", + dismissible: true, + }, + "webgl-fallback": { + id: "webgl-fallback", + tone: "info", + message: + "WebGPU is unavailable in this browser, so OpenCut is rendering with a WebGL fallback. Performance and visual fidelity may be reduced.", + dismissible: true, + }, + unavailable: { + id: "gpu-unavailable", + tone: "warning", + message: + "GPU rendering is unavailable in this browser. OpenCut may run with degraded rendering support. For the best experience, use a hardware-accelerated browser.", + dismissible: true, + }, +}; interface EditorProviderProps { projectId: string; @@ -38,8 +67,12 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { const loadProject = async () => { try { setIsLoading(true); - await initializeGpuRenderer(); - editor.renderer.setDegraded(!isGpuAvailable()); + const gpuInitializationResult = await initializeGpuRenderer(); + const rendererNotice = RENDERER_NOTICES[gpuInitializationResult.kind]; + editor.notices.setScopeNotices({ + scope: "renderer", + notices: rendererNotice ? [rendererNotice] : [], + }); await editor.project.loadProject({ id: projectId }); if (cancelled) return; diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 3e55e1d6..e71f44ce 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -10,6 +10,7 @@ import { AudioManager } from "./managers/audio-manager"; import { SelectionManager } from "./managers/selection-manager"; import { ClipboardManager } from "./managers/clipboard-manager"; import { DiagnosticsManager } from "./managers/diagnostics-manager"; +import { EditorNoticesManager } from "./managers/editor-notices-manager"; import { registerDefaultEffects } from "@/effects"; import { registerDefaultMasks } from "@/masks"; import { registerTranscriptionDiagnostics } from "@/transcription/diagnostics"; @@ -28,6 +29,7 @@ export class EditorCore { public readonly selection: SelectionManager; public readonly clipboard: ClipboardManager; public readonly diagnostics: DiagnosticsManager; + public readonly notices: EditorNoticesManager; private constructor() { registerDefaultEffects(); @@ -44,6 +46,7 @@ export class EditorCore { this.selection = new SelectionManager(this); this.clipboard = new ClipboardManager(this); this.diagnostics = new DiagnosticsManager(this); + this.notices = new EditorNoticesManager(); registerTranscriptionDiagnostics({ diagnostics: this.diagnostics }); this.playback.bindTimelineScope(); this.command.registerReactor(() => { diff --git a/apps/web/src/core/managers/editor-notices-manager.ts b/apps/web/src/core/managers/editor-notices-manager.ts new file mode 100644 index 00000000..ee459fc8 --- /dev/null +++ b/apps/web/src/core/managers/editor-notices-manager.ts @@ -0,0 +1,126 @@ +export type EditorNoticeTone = "info" | "warning" | "error"; + +export type EditorNoticeInput = { + id: string; + tone: EditorNoticeTone; + message: string; + dismissible?: boolean; +}; + +export type EditorNotice = EditorNoticeInput & { + scope: string; +}; + +export class EditorNoticesManager { + private noticesByScope = new Map(); + private dismissedIds = new Set(); + private listeners = new Set<() => void>(); + private cachedNotices: EditorNotice[] = []; + private cacheValid = false; + + setScopeNotices({ + scope, + notices, + }: { + scope: string; + notices: EditorNoticeInput[]; + }): void { + const nextNotices = notices.map((notice) => ({ + ...notice, + id: `${scope}:${notice.id}`, + scope, + })); + const previousNotices = this.noticesByScope.get(scope) ?? []; + if (areNoticeListsEqual(previousNotices, nextNotices)) { + return; + } + if (nextNotices.length === 0) { + this.noticesByScope.delete(scope); + } else { + this.noticesByScope.set(scope, nextNotices); + } + this.pruneStaleDismissals(); + this.invalidateCache(); + this.notify(); + } + + clearScope(scope: string): void { + if (!this.noticesByScope.has(scope)) { + return; + } + this.noticesByScope.delete(scope); + this.pruneStaleDismissals(); + this.invalidateCache(); + this.notify(); + } + + dismiss({ id }: { id: string }): void { + if (this.dismissedIds.has(id)) { + return; + } + this.dismissedIds.add(id); + this.invalidateCache(); + this.notify(); + } + + getNotices(): EditorNotice[] { + if (!this.cacheValid) { + this.cachedNotices = [...this.noticesByScope.values()] + .flat() + .filter((notice) => !this.dismissedIds.has(notice.id)); + this.cacheValid = true; + } + return this.cachedNotices; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private invalidateCache(): void { + this.cacheValid = false; + } + + private pruneStaleDismissals(): void { + if (this.dismissedIds.size === 0) { + return; + } + const liveIds = new Set(); + for (const notices of this.noticesByScope.values()) { + for (const notice of notices) { + liveIds.add(notice.id); + } + } + for (const id of this.dismissedIds) { + if (!liveIds.has(id)) { + this.dismissedIds.delete(id); + } + } + } + + private notify(): void { + this.listeners.forEach((listener) => { + listener(); + }); + } +} + +function areNoticeListsEqual( + left: EditorNotice[], + right: EditorNotice[], +): boolean { + if (left.length !== right.length) { + return false; + } + return left.every((notice, index) => { + const other = right[index]; + return ( + notice.id === other?.id && + notice.scope === other.scope && + notice.tone === other.tone && + notice.message === other.message && + notice.dismissible === other.dismissible + ); + }); +} diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index 7984655c..707066ed 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -6,7 +6,6 @@ import { SceneExporter } from "@/services/renderer/scene-exporter"; import { buildScene } from "@/services/renderer/scene-builder"; import { createTimelineAudioBuffer } from "@/media/audio"; import { formatTimecode } from "opencut-wasm"; -import { frameRateToFloat } from "@/fps/utils"; import { downloadBlob } from "@/utils/browser"; type SnapshotResult = @@ -15,21 +14,10 @@ type SnapshotResult = export class RendererManager { private renderTree: RootNode | null = null; - private _isDegraded = false; private listeners = new Set<() => void>(); constructor(private editor: EditorCore) {} - get isDegraded(): boolean { - return this._isDegraded; - } - - setDegraded(degraded: boolean): void { - if (this._isDegraded === degraded) return; - this._isDegraded = degraded; - this.notify(); - } - setRenderTree({ renderTree }: { renderTree: RootNode | null }): void { this.renderTree = renderTree; this.notify(); @@ -122,7 +110,9 @@ export class RendererManager { return { success: false, error: "Failed to create image" }; } - const timecode = formatTimecode({ time: renderTime, rate: fps })!.replace(/:/g, "-"); + const timecode = + formatTimecode({ time: renderTime, rate: fps })?.replace(/:/g, "-") ?? + "unknown-time"; const safeName = activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() || "snapshot"; diff --git a/apps/web/src/editor/use-editor.ts b/apps/web/src/editor/use-editor.ts index ce48e036..8a6aac2b 100644 --- a/apps/web/src/editor/use-editor.ts +++ b/apps/web/src/editor/use-editor.ts @@ -33,6 +33,7 @@ export function useEditor( editor.selection.subscribe(onChange), editor.clipboard.subscribe(onChange), editor.diagnostics.subscribe(onChange), + editor.notices.subscribe(onChange), ]; return () => { unsubscribers.forEach((unsubscribe) => { diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx index e8f5c572..85d6e816 100644 --- a/apps/web/src/preview/components/index.tsx +++ b/apps/web/src/preview/components/index.tsx @@ -188,21 +188,16 @@ function PreviewCanvas({ ); const frame = Math.floor(renderTime / ticksPerFrame); - if ( - frame === lastFrameRef.current && - renderTree === lastSceneRef.current - ) { + if (frame === lastFrameRef.current && renderTree === lastSceneRef.current) { return; } renderingRef.current = true; lastSceneRef.current = renderTree; lastFrameRef.current = frame; - renderer - .render({ node: renderTree, time: renderTime }) - .then(() => { - renderingRef.current = false; - }); + renderer.render({ node: renderTree, time: renderTime }).then(() => { + renderingRef.current = false; + }); }, [renderer, renderTree, editor.playback, editor.timeline]); useRafLoop(render); @@ -302,20 +297,20 @@ function PreviewCanvas({ ref={viewportRef} className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden" > -
+
| null = null; +export type GpuRendererInitializationResult = + | { kind: "ready" } + | { kind: "software-fallback-adapter" } + | { kind: "webgl-fallback" } + | { kind: "unavailable"; message: string }; -export function initializeGpuRenderer(): Promise { +type GpuInitDiscriminator = Exclude< + GpuRendererInitializationResult, + { kind: "unavailable" } +>["kind"]; + +let gpuAvailable = false; +let initPromise: Promise | null = null; + +export function initializeGpuRenderer(): Promise { if (!initPromise) { initPromise = initializeGpu() - .then(() => { + .then((kind): GpuRendererInitializationResult => { gpuAvailable = true; + return { kind: gpuInitKindToDiscriminator(kind) }; }) - .catch((error: unknown) => { + .catch((error: unknown): GpuRendererInitializationResult => { gpuAvailable = false; const message = error instanceof Error ? error.message : String(error); console.warn(`GPU renderer unavailable: ${message}`); + return { kind: "unavailable", message }; }); } return initPromise; } -export function isGpuAvailable(): boolean { - return gpuAvailable; +function gpuInitKindToDiscriminator(kind: GpuInitKind): GpuInitDiscriminator { + switch (kind) { + case GpuInitKind.Ready: + return "ready"; + case GpuInitKind.SoftwareFallbackAdapter: + return "software-fallback-adapter"; + case GpuInitKind.WebglFallback: + return "webgl-fallback"; + } + const unhandled: never = kind; + throw new Error(`Unhandled GpuInitKind: ${unhandled}`); } export const gpuRenderer = { diff --git a/rust/crates/compositor/src/compositor.rs b/rust/crates/compositor/src/compositor.rs index 99f0e7e9..dcd79393 100644 --- a/rust/crates/compositor/src/compositor.rs +++ b/rust/crates/compositor/src/compositor.rs @@ -3,7 +3,6 @@ use effects::{ApplyEffectsOptions, EffectPass, EffectPipeline, UniformValue}; use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext, wgpu}; use masks::{ApplyMaskFeatherOptions, MaskFeatherPipeline}; use thiserror::Error; -use wgpu::util::DeviceExt; use crate::{ BlendMode, @@ -589,23 +588,20 @@ impl Compositor { }, ], }); - let uniform_buffer = - context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("compositor-layer-uniform-buffer"), - contents: bytemuck::bytes_of(&LayerUniformBuffer { - resolution: [width as f32, height as f32], - center: [layer.transform.center_x, layer.transform.center_y], - size: [layer.transform.width, layer.transform.height], - rotation_radians: layer.transform.rotation_degrees.to_radians(), - opacity: layer.opacity, - flip_x: if layer.transform.flip_x { 1.0 } else { 0.0 }, - flip_y: if layer.transform.flip_y { 1.0 } else { 0.0 }, - _padding: [0.0; 2], - }), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = context.create_buffer_with_data( + "compositor-layer-uniform-buffer", + bytemuck::bytes_of(&LayerUniformBuffer { + resolution: [width as f32, height as f32], + center: [layer.transform.center_x, layer.transform.center_y], + size: [layer.transform.width, layer.transform.height], + rotation_radians: layer.transform.rotation_degrees.to_radians(), + opacity: layer.opacity, + flip_x: if layer.transform.flip_x { 1.0 } else { 0.0 }, + flip_y: if layer.transform.flip_y { 1.0 } else { 0.0 }, + _padding: [0.0; 2], + }), + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); let uniform_bind_group = context .device() .create_bind_group(&wgpu::BindGroupDescriptor { @@ -691,17 +687,14 @@ impl Compositor { }, ], }); - let uniform_buffer = - context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("compositor-mask-uniform-buffer"), - contents: bytemuck::bytes_of(&MaskUniformBuffer { - inverted: if inverted { 1.0 } else { 0.0 }, - _padding: [0.0; 3], - }), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = context.create_buffer_with_data( + "compositor-mask-uniform-buffer", + bytemuck::bytes_of(&MaskUniformBuffer { + inverted: if inverted { 1.0 } else { 0.0 }, + _padding: [0.0; 3], + }), + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); let uniform_bind_group = context .device() .create_bind_group(&wgpu::BindGroupDescriptor { @@ -788,17 +781,14 @@ impl Compositor { }, ], }); - let uniform_buffer = - context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("compositor-blend-uniform-buffer"), - contents: bytemuck::bytes_of(&BlendUniformBuffer { - blend_mode: blend_mode.shader_code(), - _padding: [0; 3], - }), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = context.create_buffer_with_data( + "compositor-blend-uniform-buffer", + bytemuck::bytes_of(&BlendUniformBuffer { + blend_mode: blend_mode.shader_code(), + _padding: [0; 3], + }), + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); let uniform_bind_group = context .device() .create_bind_group(&wgpu::BindGroupDescriptor { diff --git a/rust/crates/effects/src/pipeline.rs b/rust/crates/effects/src/pipeline.rs index 1ea49053..23dc7348 100644 --- a/rust/crates/effects/src/pipeline.rs +++ b/rust/crates/effects/src/pipeline.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use bytemuck::{Pod, Zeroable}; use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext}; use thiserror::Error; -use wgpu::util::DeviceExt; use crate::{EffectPass, UniformValue}; @@ -206,14 +205,11 @@ impl EffectPipeline { }, ], }); - let uniform_buffer = - context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("effects-uniform-buffer"), - contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = context.create_buffer_with_data( + "effects-uniform-buffer", + bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?), + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); let uniform_bind_group = context .device() diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs index da835280..5e397c50 100644 --- a/rust/crates/gpu/src/context.rs +++ b/rust/crates/gpu/src/context.rs @@ -1,5 +1,3 @@ -use wgpu::util::DeviceExt; - #[cfg(all(feature = "wasm", target_arch = "wasm32"))] use std::cell::RefCell; @@ -37,6 +35,52 @@ const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [ [1.0, 1.0], ]; +/// Creates a buffer and uploads `contents` via `queue.write_buffer` instead of +/// `mapped_at_creation: true`. Chrome's WebGPU backend has been rejecting the +/// mapped-at-creation path in our render loop, so this helper exists to keep +/// every buffer creation on the queue-write path. +/// +/// `usage` MUST include `COPY_DST` whenever `contents` is non-empty; otherwise +/// `queue.write_buffer` will fail validation at runtime. +fn create_buffer_with_data( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &'static str, + contents: &[u8], + usage: wgpu::BufferUsages, +) -> wgpu::Buffer { + debug_assert!( + contents.is_empty() || usage.contains(wgpu::BufferUsages::COPY_DST), + "create_buffer_with_data requires COPY_DST when contents are non-empty (label: {label})", + ); + + let unpadded_size = contents.len() as wgpu::BufferAddress; + let padded_size = if contents.is_empty() { + 0 + } else { + let align_mask = wgpu::COPY_BUFFER_ALIGNMENT - 1; + ((unpadded_size + align_mask) & !align_mask).max(wgpu::COPY_BUFFER_ALIGNMENT) + }; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: padded_size, + usage, + mapped_at_creation: false, + }); + + if !contents.is_empty() { + if padded_size == unpadded_size { + queue.write_buffer(&buffer, 0, contents); + } else { + let mut padded_contents = vec![0; padded_size as usize]; + padded_contents[..unpadded_size as usize].copy_from_slice(contents); + queue.write_buffer(&buffer, 0, &padded_contents); + } + } + + buffer +} + pub struct GpuContext { instance: wgpu::Instance, adapter: wgpu::Adapter, @@ -68,11 +112,13 @@ impl GpuContext { } else { wgpu::TextureFormat::Bgra8Unorm }; - let fullscreen_quad = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("gpu-fullscreen-quad-buffer"), - contents: bytemuck::cast_slice(&FULLSCREEN_QUAD_POSITIONS), - usage: wgpu::BufferUsages::VERTEX, - }); + let fullscreen_quad = create_buffer_with_data( + &device, + &queue, + "gpu-fullscreen-quad-buffer", + bytemuck::cast_slice(&FULLSCREEN_QUAD_POSITIONS), + wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + ); let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("gpu-linear-sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -317,6 +363,15 @@ impl GpuContext { }) } + pub fn create_buffer_with_data( + &self, + label: &'static str, + contents: &[u8], + usage: wgpu::BufferUsages, + ) -> wgpu::Buffer { + create_buffer_with_data(&self.device, &self.queue, label, contents, usage) + } + pub fn instance(&self) -> &wgpu::Instance { &self.instance } diff --git a/rust/crates/masks/src/feather.rs b/rust/crates/masks/src/feather.rs index 5ae71b6f..ee5b4343 100644 --- a/rust/crates/masks/src/feather.rs +++ b/rust/crates/masks/src/feather.rs @@ -1,6 +1,5 @@ use bytemuck::{Pod, Zeroable}; use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext}; -use wgpu::util::DeviceExt; use crate::SdfPipeline; @@ -234,18 +233,15 @@ impl MaskFeatherPipeline { }, ], }); - let uniform_buffer = - context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("gpu-mask-distance-uniform-buffer"), - contents: bytemuck::bytes_of(&DistanceUniformBuffer { - resolution: [width as f32, height as f32], - feather_half: feather / 2.0, - _padding: 0.0, - }), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = context.create_buffer_with_data( + "gpu-mask-distance-uniform-buffer", + bytemuck::bytes_of(&DistanceUniformBuffer { + resolution: [width as f32, height as f32], + feather_half: feather / 2.0, + _padding: 0.0, + }), + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); let uniform_bind_group = context .device() .create_bind_group(&wgpu::BindGroupDescriptor { diff --git a/rust/crates/masks/src/sdf.rs b/rust/crates/masks/src/sdf.rs index f0d93212..73f81647 100644 --- a/rust/crates/masks/src/sdf.rs +++ b/rust/crates/masks/src/sdf.rs @@ -1,6 +1,5 @@ use bytemuck::{Pod, Zeroable}; use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext}; -use wgpu::util::DeviceExt; const JFA_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl"); const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl"); @@ -243,14 +242,11 @@ impl SdfPipeline { }, ], }); - let uniform_buffer = - context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("gpu-sdf-uniform-buffer"), - contents: uniform_buffer_bytes, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = context.create_buffer_with_data( + "gpu-sdf-uniform-buffer", + uniform_buffer_bytes, + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); let uniform_bind_group = context .device() .create_bind_group(&wgpu::BindGroupDescriptor { diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml index 3fbfe5ba..0f2bfc56 100644 --- a/rust/wasm/Cargo.toml +++ b/rust/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencut-wasm" -version = "0.2.9" +version = "0.2.10" edition = "2024" description = "Shared video editor logic compiled to WebAssembly" repository = "https://github.com/opencut/opencut" diff --git a/rust/wasm/src/gpu.rs b/rust/wasm/src/gpu.rs index 52c051f0..17393246 100644 --- a/rust/wasm/src/gpu.rs +++ b/rust/wasm/src/gpu.rs @@ -9,45 +9,48 @@ use masks::MaskFeatherPipeline; use serde::Deserialize; use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; +/// Classifies the GPU path that initialization landed on, so the UI can decide +/// whether to surface a degraded-rendering notice. This is the single source of +/// truth — JS never calls `requestAdapter()` itself to figure this out. +#[wasm_bindgen] +#[derive(Copy, Clone)] +pub enum GpuInitKind { + /// WebGPU on a hardware adapter. + Ready, + /// WebGPU but on a CPU/software adapter (e.g. SwiftShader). Preview will + /// likely be slow or blank. + SoftwareFallbackAdapter, + /// WebGPU was unavailable; we fell back to WebGL. Works but with reduced + /// capability and performance. + WebglFallback, +} + pub(crate) struct GpuRuntime { pub(crate) context: GpuContext, pub(crate) effects: EffectPipeline, pub(crate) masks: MaskFeatherPipeline, + kind: GpuInitKind, } thread_local! { static GPU_RUNTIME: RefCell> = const { RefCell::new(None) }; } -fn set_panic_hook() { - static SET_HOOK: std::sync::Once = std::sync::Once::new(); - SET_HOOK.call_once(|| { - std::panic::set_hook(Box::new(|info| { - // Store the full panic message in window.__wasmPanic so the JS catch block - // can surface it instead of the opaque "Unreachable" WASM trap message. - if let Some(window) = web_sys::window() { - let _ = Reflect::set( - &window, - &JsValue::from_str("__wasmPanic"), - &JsValue::from_str(&info.to_string()), - ); - } - console_error_panic_hook::hook(info); - })); - }); -} - +/// Initializes the shared GPU runtime and reports which path it landed on. +/// +/// Concurrent callers are expected to be serialized by the JS layer (it caches +/// the returned promise). Without that, two parallel calls would each create a +/// `GpuContext` and the second would silently overwrite the first. #[wasm_bindgen(js_name = initializeGpu)] -pub async fn initialize_gpu() -> Result<(), JsValue> { - set_panic_hook(); - - if GPU_RUNTIME.with(|runtime| runtime.borrow().is_some()) { - return Ok(()); +pub async fn initialize_gpu() -> Result { + if let Some(kind) = GPU_RUNTIME.with(|runtime| runtime.borrow().as_ref().map(|r| r.kind)) { + return Ok(kind); } let context = GpuContext::new() .await .map_err(|error| JsValue::from_str(&error.to_string()))?; + let kind = classify_adapter(context.adapter()); let effects = EffectPipeline::new(&context); let masks = MaskFeatherPipeline::new(&context); @@ -56,10 +59,24 @@ pub async fn initialize_gpu() -> Result<(), JsValue> { context, effects, masks, + kind, })); }); - Ok(()) + Ok(kind) +} + +fn classify_adapter(adapter: &wgpu::Adapter) -> GpuInitKind { + let info = adapter.get_info(); + if info.backend == wgpu::Backend::Gl { + GpuInitKind::WebglFallback + } else if info.device_type == wgpu::DeviceType::Cpu { + GpuInitKind::SoftwareFallbackAdapter + } else { + // `DeviceType::Other` is bucketed with hardware adapters; some + // virtualized GPUs report this way and we have no better signal. + GpuInitKind::Ready + } } pub(crate) fn with_gpu_runtime( diff --git a/rust/wasm/src/panic.rs b/rust/wasm/src/panic.rs new file mode 100644 index 00000000..eb34e642 --- /dev/null +++ b/rust/wasm/src/panic.rs @@ -0,0 +1,25 @@ +#![cfg(target_arch = "wasm32")] + +//! Wasm panic hook. +//! +//! Forwards Rust panics to the browser console via `console_error_panic_hook`, +//! and additionally stashes the formatted panic message on `window.__wasmPanic` +//! so JS catch blocks can surface the real cause instead of the opaque +//! "Unreachable" wasm trap that wasm-bindgen otherwise produces. + +use js_sys::Reflect; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; + +#[wasm_bindgen(start)] +pub fn install() { + std::panic::set_hook(Box::new(|info| { + if let Some(window) = web_sys::window() { + let _ = Reflect::set( + &window, + &JsValue::from_str("__wasmPanic"), + &JsValue::from_str(&info.to_string()), + ); + } + console_error_panic_hook::hook(info); + })); +} diff --git a/rust/wasm/src/wasm.rs b/rust/wasm/src/wasm.rs index 0687ff4b..713eab68 100644 --- a/rust/wasm/src/wasm.rs +++ b/rust/wasm/src/wasm.rs @@ -7,6 +7,8 @@ mod gpu; #[cfg(target_arch = "wasm32")] mod masks; #[cfg(target_arch = "wasm32")] +mod panic; +#[cfg(target_arch = "wasm32")] mod perf; #[cfg(target_arch = "wasm32")]