fix: surface degraded GPU renderer notices
This commit is contained in:
parent
6a22a3ecbd
commit
b305917ac1
|
|
@ -3820,7 +3820,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}));
|
||||
}
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
Loading…
Reference in New Issue