fix: surface degraded GPU renderer notices

This commit is contained in:
Maze Winther 2026-04-25 18:14:48 +02:00
parent 6a22a3ecbd
commit b305917ac1
20 changed files with 452 additions and 178 deletions

2
Cargo.lock generated
View File

@ -3820,7 +3820,7 @@ dependencies = [
[[package]]
name = "opencut-wasm"
version = "0.2.9"
version = "0.2.10"
dependencies = [
"bridge",
"compositor",

View File

@ -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",

View File

@ -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() {
<MobileGate>
<EditorProvider projectId={projectId}>
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<DegradedRendererBanner />
<EditorNoticeBar />
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
@ -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 (
<div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span>For the best experience, open OpenCut in Chrome.</span>
<Button
variant="text"
size="icon"
className="p-0 w-auto [&_svg]:size-3.5"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
>
<HugeiconsIcon icon={Cancel01Icon} />
</Button>
</div>
);
}
function EditorLayout() {
usePasteMedia();
const { panels, setPanel } = usePanelStore();

View File

@ -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 (
<div className="flex flex-col">
{notices.map((notice) => (
<div
key={notice.id}
className={cn(
"border-b h-9 flex items-center justify-center gap-2 px-3 text-xs",
toneClassName[notice.tone],
)}
>
<span>{notice.message}</span>
{notice.dismissible ? (
<Button
variant="text"
size="icon"
className="p-0 w-auto [&_svg]:size-3.5"
onClick={() => editor.notices.dismiss({ id: notice.id })}
aria-label="Dismiss"
>
<HugeiconsIcon icon={Cancel01Icon} />
</Button>
) : null}
</div>
))}
</div>
);
}

View File

@ -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;

View File

@ -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(() => {

View File

@ -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<string, EditorNotice[]>();
private dismissedIds = new Set<string>();
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<string>();
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
);
});
}

View File

@ -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";

View File

@ -33,6 +33,7 @@ export function useEditor<T>(
editor.selection.subscribe(onChange),
editor.clipboard.subscribe(onChange),
editor.diagnostics.subscribe(onChange),
editor.notices.subscribe(onChange),
];
return () => {
unsubscribers.forEach((unsubscribe) => {

View File

@ -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"
>
<div
ref={canvasMountRef}
className="absolute block border"
style={{
left: viewport.sceneLeft,
top: viewport.sceneTop,
width: viewport.sceneWidth,
height: viewport.sceneHeight,
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<div
ref={canvasMountRef}
className="absolute block border"
style={{
left: viewport.sceneLeft,
top: viewport.sceneTop,
width: viewport.sceneWidth,
height: viewport.sceneHeight,
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<PreviewOverlayLayer
instances={overlayInstances}
plane="under-interaction"

View File

@ -1,30 +1,53 @@
import {
applyEffectPasses,
applyMaskFeather as applyMaskFeatherWasm,
GpuInitKind,
initializeGpu,
} from "opencut-wasm";
import type { EffectPass, EffectUniformValue } from "@/effects/types";
let gpuAvailable = false;
let initPromise: Promise<void> | null = null;
export type GpuRendererInitializationResult =
| { kind: "ready" }
| { kind: "software-fallback-adapter" }
| { kind: "webgl-fallback" }
| { kind: "unavailable"; message: string };
export function initializeGpuRenderer(): Promise<void> {
type GpuInitDiscriminator = Exclude<
GpuRendererInitializationResult,
{ kind: "unavailable" }
>["kind"];
let gpuAvailable = false;
let initPromise: Promise<GpuRendererInitializationResult> | null = null;
export function initializeGpuRenderer(): Promise<GpuRendererInitializationResult> {
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 = {

View File

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

View File

@ -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()

View File

@ -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
}

View File

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

View File

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

View File

@ -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"

View File

@ -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<Option<GpuRuntime>> = 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<GpuInitKind, JsValue> {
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<T>(

25
rust/wasm/src/panic.rs Normal file
View File

@ -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);
}));
}

View File

@ -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")]