fix: surface degraded GPU renderer notices
This commit is contained in:
parent
6a22a3ecbd
commit
b305917ac1
|
|
@ -3820,7 +3820,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.2.9"
|
version = "0.2.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bridge",
|
"bridge",
|
||||||
"compositor",
|
"compositor",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.2.9",
|
"opencut-wasm": "^0.2.10",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,15 @@ import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||||
import { Timeline } from "@/timeline/components";
|
import { Timeline } from "@/timeline/components";
|
||||||
import { PreviewPanel } from "@/preview/components";
|
import { PreviewPanel } from "@/preview/components";
|
||||||
import { EditorHeader } from "@/components/editor/editor-header";
|
import { EditorHeader } from "@/components/editor/editor-header";
|
||||||
|
import { EditorNoticeBar } from "@/components/editor/editor-notice-bar";
|
||||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||||
import { Onboarding } from "@/components/editor/onboarding";
|
import { Onboarding } from "@/components/editor/onboarding";
|
||||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||||
import { usePanelStore } from "@/editor/panel-store";
|
import { usePanelStore } from "@/editor/panel-store";
|
||||||
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { usePasteMedia } from "@/media/use-paste-media";
|
import { usePasteMedia } from "@/media/use-paste-media";
|
||||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } 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 { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||||
import {
|
import {
|
||||||
createPreviewOverlayControl,
|
createPreviewOverlayControl,
|
||||||
|
|
@ -43,7 +41,7 @@ export default function Editor() {
|
||||||
<MobileGate>
|
<MobileGate>
|
||||||
<EditorProvider projectId={projectId}>
|
<EditorProvider projectId={projectId}>
|
||||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||||
<DegradedRendererBanner />
|
<EditorNoticeBar />
|
||||||
<EditorHeader />
|
<EditorHeader />
|
||||||
<div className="min-h-0 min-w-0 flex-1">
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
<EditorLayout />
|
<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() {
|
function EditorLayout() {
|
||||||
usePasteMedia();
|
usePasteMedia();
|
||||||
const { panels, setPanel } = usePanelStore();
|
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 { loadFontAtlas } from "@/fonts/google-fonts";
|
||||||
import {
|
import {
|
||||||
initializeGpuRenderer,
|
initializeGpuRenderer,
|
||||||
isGpuAvailable,
|
type GpuRendererInitializationResult,
|
||||||
} from "@/services/renderer/gpu-renderer";
|
} 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 {
|
interface EditorProviderProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -38,8 +67,12 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||||
const loadProject = async () => {
|
const loadProject = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await initializeGpuRenderer();
|
const gpuInitializationResult = await initializeGpuRenderer();
|
||||||
editor.renderer.setDegraded(!isGpuAvailable());
|
const rendererNotice = RENDERER_NOTICES[gpuInitializationResult.kind];
|
||||||
|
editor.notices.setScopeNotices({
|
||||||
|
scope: "renderer",
|
||||||
|
notices: rendererNotice ? [rendererNotice] : [],
|
||||||
|
});
|
||||||
await editor.project.loadProject({ id: projectId });
|
await editor.project.loadProject({ id: projectId });
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { AudioManager } from "./managers/audio-manager";
|
||||||
import { SelectionManager } from "./managers/selection-manager";
|
import { SelectionManager } from "./managers/selection-manager";
|
||||||
import { ClipboardManager } from "./managers/clipboard-manager";
|
import { ClipboardManager } from "./managers/clipboard-manager";
|
||||||
import { DiagnosticsManager } from "./managers/diagnostics-manager";
|
import { DiagnosticsManager } from "./managers/diagnostics-manager";
|
||||||
|
import { EditorNoticesManager } from "./managers/editor-notices-manager";
|
||||||
import { registerDefaultEffects } from "@/effects";
|
import { registerDefaultEffects } from "@/effects";
|
||||||
import { registerDefaultMasks } from "@/masks";
|
import { registerDefaultMasks } from "@/masks";
|
||||||
import { registerTranscriptionDiagnostics } from "@/transcription/diagnostics";
|
import { registerTranscriptionDiagnostics } from "@/transcription/diagnostics";
|
||||||
|
|
@ -28,6 +29,7 @@ export class EditorCore {
|
||||||
public readonly selection: SelectionManager;
|
public readonly selection: SelectionManager;
|
||||||
public readonly clipboard: ClipboardManager;
|
public readonly clipboard: ClipboardManager;
|
||||||
public readonly diagnostics: DiagnosticsManager;
|
public readonly diagnostics: DiagnosticsManager;
|
||||||
|
public readonly notices: EditorNoticesManager;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
registerDefaultEffects();
|
registerDefaultEffects();
|
||||||
|
|
@ -44,6 +46,7 @@ export class EditorCore {
|
||||||
this.selection = new SelectionManager(this);
|
this.selection = new SelectionManager(this);
|
||||||
this.clipboard = new ClipboardManager(this);
|
this.clipboard = new ClipboardManager(this);
|
||||||
this.diagnostics = new DiagnosticsManager(this);
|
this.diagnostics = new DiagnosticsManager(this);
|
||||||
|
this.notices = new EditorNoticesManager();
|
||||||
registerTranscriptionDiagnostics({ diagnostics: this.diagnostics });
|
registerTranscriptionDiagnostics({ diagnostics: this.diagnostics });
|
||||||
this.playback.bindTimelineScope();
|
this.playback.bindTimelineScope();
|
||||||
this.command.registerReactor(() => {
|
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 { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { createTimelineAudioBuffer } from "@/media/audio";
|
import { createTimelineAudioBuffer } from "@/media/audio";
|
||||||
import { formatTimecode } from "opencut-wasm";
|
import { formatTimecode } from "opencut-wasm";
|
||||||
import { frameRateToFloat } from "@/fps/utils";
|
|
||||||
import { downloadBlob } from "@/utils/browser";
|
import { downloadBlob } from "@/utils/browser";
|
||||||
|
|
||||||
type SnapshotResult =
|
type SnapshotResult =
|
||||||
|
|
@ -15,21 +14,10 @@ type SnapshotResult =
|
||||||
|
|
||||||
export class RendererManager {
|
export class RendererManager {
|
||||||
private renderTree: RootNode | null = null;
|
private renderTree: RootNode | null = null;
|
||||||
private _isDegraded = false;
|
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {}
|
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 {
|
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
|
||||||
this.renderTree = renderTree;
|
this.renderTree = renderTree;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
@ -122,7 +110,9 @@ export class RendererManager {
|
||||||
return { success: false, error: "Failed to create image" };
|
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 =
|
const safeName =
|
||||||
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
||||||
"snapshot";
|
"snapshot";
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export function useEditor<T>(
|
||||||
editor.selection.subscribe(onChange),
|
editor.selection.subscribe(onChange),
|
||||||
editor.clipboard.subscribe(onChange),
|
editor.clipboard.subscribe(onChange),
|
||||||
editor.diagnostics.subscribe(onChange),
|
editor.diagnostics.subscribe(onChange),
|
||||||
|
editor.notices.subscribe(onChange),
|
||||||
];
|
];
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribers.forEach((unsubscribe) => {
|
unsubscribers.forEach((unsubscribe) => {
|
||||||
|
|
|
||||||
|
|
@ -188,21 +188,16 @@ function PreviewCanvas({
|
||||||
);
|
);
|
||||||
const frame = Math.floor(renderTime / ticksPerFrame);
|
const frame = Math.floor(renderTime / ticksPerFrame);
|
||||||
|
|
||||||
if (
|
if (frame === lastFrameRef.current && renderTree === lastSceneRef.current) {
|
||||||
frame === lastFrameRef.current &&
|
|
||||||
renderTree === lastSceneRef.current
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderingRef.current = true;
|
renderingRef.current = true;
|
||||||
lastSceneRef.current = renderTree;
|
lastSceneRef.current = renderTree;
|
||||||
lastFrameRef.current = frame;
|
lastFrameRef.current = frame;
|
||||||
renderer
|
renderer.render({ node: renderTree, time: renderTime }).then(() => {
|
||||||
.render({ node: renderTree, time: renderTime })
|
renderingRef.current = false;
|
||||||
.then(() => {
|
});
|
||||||
renderingRef.current = false;
|
|
||||||
});
|
|
||||||
}, [renderer, renderTree, editor.playback, editor.timeline]);
|
}, [renderer, renderTree, editor.playback, editor.timeline]);
|
||||||
|
|
||||||
useRafLoop(render);
|
useRafLoop(render);
|
||||||
|
|
@ -302,20 +297,20 @@ function PreviewCanvas({
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
|
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={canvasMountRef}
|
ref={canvasMountRef}
|
||||||
className="absolute block border"
|
className="absolute block border"
|
||||||
style={{
|
style={{
|
||||||
left: viewport.sceneLeft,
|
left: viewport.sceneLeft,
|
||||||
top: viewport.sceneTop,
|
top: viewport.sceneTop,
|
||||||
width: viewport.sceneWidth,
|
width: viewport.sceneWidth,
|
||||||
height: viewport.sceneHeight,
|
height: viewport.sceneHeight,
|
||||||
background:
|
background:
|
||||||
activeProject.settings.background.type === "blur"
|
activeProject.settings.background.type === "blur"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: activeProject?.settings.background.color,
|
: activeProject?.settings.background.color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PreviewOverlayLayer
|
<PreviewOverlayLayer
|
||||||
instances={overlayInstances}
|
instances={overlayInstances}
|
||||||
plane="under-interaction"
|
plane="under-interaction"
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,53 @@
|
||||||
import {
|
import {
|
||||||
applyEffectPasses,
|
applyEffectPasses,
|
||||||
applyMaskFeather as applyMaskFeatherWasm,
|
applyMaskFeather as applyMaskFeatherWasm,
|
||||||
|
GpuInitKind,
|
||||||
initializeGpu,
|
initializeGpu,
|
||||||
} from "opencut-wasm";
|
} from "opencut-wasm";
|
||||||
import type { EffectPass, EffectUniformValue } from "@/effects/types";
|
import type { EffectPass, EffectUniformValue } from "@/effects/types";
|
||||||
|
|
||||||
let gpuAvailable = false;
|
export type GpuRendererInitializationResult =
|
||||||
let initPromise: Promise<void> | null = null;
|
| { 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) {
|
if (!initPromise) {
|
||||||
initPromise = initializeGpu()
|
initPromise = initializeGpu()
|
||||||
.then(() => {
|
.then((kind): GpuRendererInitializationResult => {
|
||||||
gpuAvailable = true;
|
gpuAvailable = true;
|
||||||
|
return { kind: gpuInitKindToDiscriminator(kind) };
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown): GpuRendererInitializationResult => {
|
||||||
gpuAvailable = false;
|
gpuAvailable = false;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.warn(`GPU renderer unavailable: ${message}`);
|
console.warn(`GPU renderer unavailable: ${message}`);
|
||||||
|
return { kind: "unavailable", message };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return initPromise;
|
return initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGpuAvailable(): boolean {
|
function gpuInitKindToDiscriminator(kind: GpuInitKind): GpuInitDiscriminator {
|
||||||
return gpuAvailable;
|
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 = {
|
export const gpuRenderer = {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ use effects::{ApplyEffectsOptions, EffectPass, EffectPipeline, UniformValue};
|
||||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext, wgpu};
|
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext, wgpu};
|
||||||
use masks::{ApplyMaskFeatherOptions, MaskFeatherPipeline};
|
use masks::{ApplyMaskFeatherOptions, MaskFeatherPipeline};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
BlendMode,
|
BlendMode,
|
||||||
|
|
@ -589,23 +588,20 @@ impl Compositor {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer =
|
let uniform_buffer = context.create_buffer_with_data(
|
||||||
context
|
"compositor-layer-uniform-buffer",
|
||||||
.device()
|
bytemuck::bytes_of(&LayerUniformBuffer {
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
resolution: [width as f32, height as f32],
|
||||||
label: Some("compositor-layer-uniform-buffer"),
|
center: [layer.transform.center_x, layer.transform.center_y],
|
||||||
contents: bytemuck::bytes_of(&LayerUniformBuffer {
|
size: [layer.transform.width, layer.transform.height],
|
||||||
resolution: [width as f32, height as f32],
|
rotation_radians: layer.transform.rotation_degrees.to_radians(),
|
||||||
center: [layer.transform.center_x, layer.transform.center_y],
|
opacity: layer.opacity,
|
||||||
size: [layer.transform.width, layer.transform.height],
|
flip_x: if layer.transform.flip_x { 1.0 } else { 0.0 },
|
||||||
rotation_radians: layer.transform.rotation_degrees.to_radians(),
|
flip_y: if layer.transform.flip_y { 1.0 } else { 0.0 },
|
||||||
opacity: layer.opacity,
|
_padding: [0.0; 2],
|
||||||
flip_x: if layer.transform.flip_x { 1.0 } else { 0.0 },
|
}),
|
||||||
flip_y: if layer.transform.flip_y { 1.0 } else { 0.0 },
|
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
_padding: [0.0; 2],
|
);
|
||||||
}),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let uniform_bind_group = context
|
let uniform_bind_group = context
|
||||||
.device()
|
.device()
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
|
@ -691,17 +687,14 @@ impl Compositor {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer =
|
let uniform_buffer = context.create_buffer_with_data(
|
||||||
context
|
"compositor-mask-uniform-buffer",
|
||||||
.device()
|
bytemuck::bytes_of(&MaskUniformBuffer {
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
inverted: if inverted { 1.0 } else { 0.0 },
|
||||||
label: Some("compositor-mask-uniform-buffer"),
|
_padding: [0.0; 3],
|
||||||
contents: bytemuck::bytes_of(&MaskUniformBuffer {
|
}),
|
||||||
inverted: if inverted { 1.0 } else { 0.0 },
|
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
_padding: [0.0; 3],
|
);
|
||||||
}),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let uniform_bind_group = context
|
let uniform_bind_group = context
|
||||||
.device()
|
.device()
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
|
@ -788,17 +781,14 @@ impl Compositor {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer =
|
let uniform_buffer = context.create_buffer_with_data(
|
||||||
context
|
"compositor-blend-uniform-buffer",
|
||||||
.device()
|
bytemuck::bytes_of(&BlendUniformBuffer {
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
blend_mode: blend_mode.shader_code(),
|
||||||
label: Some("compositor-blend-uniform-buffer"),
|
_padding: [0; 3],
|
||||||
contents: bytemuck::bytes_of(&BlendUniformBuffer {
|
}),
|
||||||
blend_mode: blend_mode.shader_code(),
|
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
_padding: [0; 3],
|
);
|
||||||
}),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let uniform_bind_group = context
|
let uniform_bind_group = context
|
||||||
.device()
|
.device()
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ use std::collections::HashMap;
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
use crate::{EffectPass, UniformValue};
|
use crate::{EffectPass, UniformValue};
|
||||||
|
|
||||||
|
|
@ -206,14 +205,11 @@ impl EffectPipeline {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer =
|
let uniform_buffer = context.create_buffer_with_data(
|
||||||
context
|
"effects-uniform-buffer",
|
||||||
.device()
|
bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
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_bind_group =
|
let uniform_bind_group =
|
||||||
context
|
context
|
||||||
.device()
|
.device()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
|
@ -37,6 +35,52 @@ const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
|
||||||
[1.0, 1.0],
|
[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 {
|
pub struct GpuContext {
|
||||||
instance: wgpu::Instance,
|
instance: wgpu::Instance,
|
||||||
adapter: wgpu::Adapter,
|
adapter: wgpu::Adapter,
|
||||||
|
|
@ -68,11 +112,13 @@ impl GpuContext {
|
||||||
} else {
|
} else {
|
||||||
wgpu::TextureFormat::Bgra8Unorm
|
wgpu::TextureFormat::Bgra8Unorm
|
||||||
};
|
};
|
||||||
let fullscreen_quad = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
let fullscreen_quad = create_buffer_with_data(
|
||||||
label: Some("gpu-fullscreen-quad-buffer"),
|
&device,
|
||||||
contents: bytemuck::cast_slice(&FULLSCREEN_QUAD_POSITIONS),
|
&queue,
|
||||||
usage: wgpu::BufferUsages::VERTEX,
|
"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 {
|
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
label: Some("gpu-linear-sampler"),
|
label: Some("gpu-linear-sampler"),
|
||||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
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 {
|
pub fn instance(&self) -> &wgpu::Instance {
|
||||||
&self.instance
|
&self.instance
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
use crate::SdfPipeline;
|
use crate::SdfPipeline;
|
||||||
|
|
||||||
|
|
@ -234,18 +233,15 @@ impl MaskFeatherPipeline {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer =
|
let uniform_buffer = context.create_buffer_with_data(
|
||||||
context
|
"gpu-mask-distance-uniform-buffer",
|
||||||
.device()
|
bytemuck::bytes_of(&DistanceUniformBuffer {
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
resolution: [width as f32, height as f32],
|
||||||
label: Some("gpu-mask-distance-uniform-buffer"),
|
feather_half: feather / 2.0,
|
||||||
contents: bytemuck::bytes_of(&DistanceUniformBuffer {
|
_padding: 0.0,
|
||||||
resolution: [width as f32, height as f32],
|
}),
|
||||||
feather_half: feather / 2.0,
|
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
_padding: 0.0,
|
);
|
||||||
}),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let uniform_bind_group = context
|
let uniform_bind_group = context
|
||||||
.device()
|
.device()
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
const JFA_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl");
|
const JFA_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl");
|
||||||
const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl");
|
const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl");
|
||||||
|
|
@ -243,14 +242,11 @@ impl SdfPipeline {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer =
|
let uniform_buffer = context.create_buffer_with_data(
|
||||||
context
|
"gpu-sdf-uniform-buffer",
|
||||||
.device()
|
uniform_buffer_bytes,
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
label: Some("gpu-sdf-uniform-buffer"),
|
);
|
||||||
contents: uniform_buffer_bytes,
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let uniform_bind_group = context
|
let uniform_bind_group = context
|
||||||
.device()
|
.device()
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.2.9"
|
version = "0.2.10"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Shared video editor logic compiled to WebAssembly"
|
description = "Shared video editor logic compiled to WebAssembly"
|
||||||
repository = "https://github.com/opencut/opencut"
|
repository = "https://github.com/opencut/opencut"
|
||||||
|
|
|
||||||
|
|
@ -9,45 +9,48 @@ use masks::MaskFeatherPipeline;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
|
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) struct GpuRuntime {
|
||||||
pub(crate) context: GpuContext,
|
pub(crate) context: GpuContext,
|
||||||
pub(crate) effects: EffectPipeline,
|
pub(crate) effects: EffectPipeline,
|
||||||
pub(crate) masks: MaskFeatherPipeline,
|
pub(crate) masks: MaskFeatherPipeline,
|
||||||
|
kind: GpuInitKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static GPU_RUNTIME: RefCell<Option<GpuRuntime>> = const { RefCell::new(None) };
|
static GPU_RUNTIME: RefCell<Option<GpuRuntime>> = const { RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_panic_hook() {
|
/// Initializes the shared GPU runtime and reports which path it landed on.
|
||||||
static SET_HOOK: std::sync::Once = std::sync::Once::new();
|
///
|
||||||
SET_HOOK.call_once(|| {
|
/// Concurrent callers are expected to be serialized by the JS layer (it caches
|
||||||
std::panic::set_hook(Box::new(|info| {
|
/// the returned promise). Without that, two parallel calls would each create a
|
||||||
// Store the full panic message in window.__wasmPanic so the JS catch block
|
/// `GpuContext` and the second would silently overwrite the first.
|
||||||
// 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);
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = initializeGpu)]
|
#[wasm_bindgen(js_name = initializeGpu)]
|
||||||
pub async fn initialize_gpu() -> Result<(), JsValue> {
|
pub async fn initialize_gpu() -> Result<GpuInitKind, JsValue> {
|
||||||
set_panic_hook();
|
if let Some(kind) = GPU_RUNTIME.with(|runtime| runtime.borrow().as_ref().map(|r| r.kind)) {
|
||||||
|
return Ok(kind);
|
||||||
if GPU_RUNTIME.with(|runtime| runtime.borrow().is_some()) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let context = GpuContext::new()
|
let context = GpuContext::new()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||||
|
let kind = classify_adapter(context.adapter());
|
||||||
let effects = EffectPipeline::new(&context);
|
let effects = EffectPipeline::new(&context);
|
||||||
let masks = MaskFeatherPipeline::new(&context);
|
let masks = MaskFeatherPipeline::new(&context);
|
||||||
|
|
||||||
|
|
@ -56,10 +59,24 @@ pub async fn initialize_gpu() -> Result<(), JsValue> {
|
||||||
context,
|
context,
|
||||||
effects,
|
effects,
|
||||||
masks,
|
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>(
|
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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod masks;
|
mod masks;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod panic;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod perf;
|
mod perf;
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue