summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock2
-rw-r--r--apps/desktop/src/main.rs4
-rw-r--r--apps/web/package.json2
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx18
-rw-r--r--apps/web/src/core/managers/project-manager.ts10
-rw-r--r--apps/web/src/services/renderer/effect-preview.ts66
-rw-r--r--bun.lock10
-rw-r--r--rust/crates/compositor/src/compositor.rs53
-rw-r--r--rust/crates/compositor/src/shaders/layer.wgsl1
-rw-r--r--rust/crates/gpu/Cargo.toml2
-rw-r--r--rust/crates/gpu/src/context.rs207
-rw-r--r--rust/wasm/Cargo.toml2
-rw-r--r--rust/wasm/src/compositor.rs50
-rw-r--r--rust/wasm/src/gpu.rs20
14 files changed, 329 insertions, 118 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 2d767644..962e7d38 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3820,7 +3820,7 @@ dependencies = [
[[package]]
name = "opencut-wasm"
-version = "0.2.6"
+version = "0.2.8"
dependencies = [
"bridge",
"compositor",
diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs
index e456f136..0ba70959 100644
--- a/apps/desktop/src/main.rs
+++ b/apps/desktop/src/main.rs
@@ -1,6 +1,6 @@
use gpui::{
- App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
- prelude::*, px, rgb, size,
+ div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
+ WindowBounds, WindowOptions,
};
struct AppWindow {
diff --git a/apps/web/package.json b/apps/web/package.json
index 69aa7931..065d4ffc 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -52,7 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "^0.2.6",
+ "opencut-wasm": "^0.2.8",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index cac954ba..98e6d4c6 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -10,7 +10,10 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
-import { initializeGpuRenderer, isGpuAvailable } from "@/services/renderer/gpu-renderer";
+import {
+ initializeGpuRenderer,
+ isGpuAvailable,
+} from "@/services/renderer/gpu-renderer";
interface EditorProviderProps {
projectId: string;
@@ -62,9 +65,16 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
setIsLoading(false);
}
} else {
- setError(
- err instanceof Error ? err.message : "Failed to load project",
- );
+ const wasmPanic = (window as Window & { __wasmPanic?: string })
+ .__wasmPanic;
+ if (wasmPanic) {
+ delete (window as Window & { __wasmPanic?: string }).__wasmPanic;
+ setError(wasmPanic);
+ } else {
+ setError(
+ err instanceof Error ? err.message : "Failed to load project",
+ );
+ }
setIsLoading(false);
}
}
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index 81b237da..8555786d 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -167,9 +167,13 @@ export class ProjectManager {
});
if (!project.metadata.thumbnail) {
- const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
- if (didUpdateThumbnail) {
- await this.saveCurrentProject();
+ try {
+ const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
+ if (didUpdateThumbnail) {
+ await this.saveCurrentProject();
+ }
+ } catch (error) {
+ console.error("Failed to generate project thumbnail:", error);
}
}
} catch (error) {
diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts
index 8e081039..06e64d0e 100644
--- a/apps/web/src/services/renderer/effect-preview.ts
+++ b/apps/web/src/services/renderer/effect-preview.ts
@@ -18,11 +18,7 @@ class EffectPreviewService {
this.loadPreviewImage();
}
- onPreviewImageReady({
- callback,
- }: {
- callback: () => void;
- }): () => void {
+ onPreviewImageReady({ callback }: { callback: () => void }): () => void {
this.onReadyCallbacks.add(callback);
return () => this.onReadyCallbacks.delete(callback);
}
@@ -39,35 +35,47 @@ class EffectPreviewService {
uniformDimensions?: { width: number; height: number };
}): void {
const size = PREVIEW_SIZE;
+ const targetCtx = targetCanvas.getContext(
+ "2d",
+ ) as CanvasRenderingContext2D | null;
+ if (!targetCtx) {
+ return;
+ }
+
+ targetCanvas.width = size;
+ targetCanvas.height = size;
+
const source = this.getTestSource({ width: size, height: size });
- if (!source) return;
+ if (!source) {
+ targetCtx.clearRect(0, 0, size, size);
+ return;
+ }
- const definition = effectsRegistry.get(effectType);
- const resolvedParams =
- Object.keys(params).length > 0
- ? params
- : buildDefaultParamValues(definition.params);
+ try {
+ const definition = effectsRegistry.get(effectType);
+ const resolvedParams =
+ Object.keys(params).length > 0
+ ? params
+ : buildDefaultParamValues(definition.params);
- const passes = resolveEffectPasses({
- definition,
- effectParams: resolvedParams,
- width: uniformDimensions?.width ?? size,
- height: uniformDimensions?.height ?? size,
- });
- const result = this.applyGpuEffect({
- source,
- width: size,
- height: size,
- passes,
- });
+ const passes = resolveEffectPasses({
+ definition,
+ effectParams: resolvedParams,
+ width: uniformDimensions?.width ?? size,
+ height: uniformDimensions?.height ?? size,
+ });
+ const result = this.applyGpuEffect({
+ source,
+ width: size,
+ height: size,
+ passes,
+ });
- const targetCtx = targetCanvas.getContext(
- "2d",
- ) as CanvasRenderingContext2D | null;
- if (targetCtx) {
- targetCanvas.width = size;
- targetCanvas.height = size;
targetCtx.drawImage(result, 0, 0, size, size);
+ } catch (error) {
+ console.warn("Failed to render effect preview", { effectType, error });
+ targetCtx.clearRect(0, 0, size, size);
+ targetCtx.drawImage(source, 0, 0, size, size);
}
}
diff --git a/bun.lock b/bun.lock
index acb2f6e8..cb36d56e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -54,7 +54,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "file:../../rust/wasm/pkg",
+ "opencut-wasm": "^0.2.8",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -785,7 +785,7 @@
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+ "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
@@ -875,7 +875,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -1359,6 +1359,8 @@
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
+ "opencut-wasm": ["opencut-wasm@0.2.8", "", {}, "sha512-R1cXB4HuTC5RdbGRYD0Q2n9rWQWDRB7KMwGJnbjrhnnwxBbR5XDu/3tL7hlzb/RZ7VZcaX5OGHZ1QgL6ex/2cQ=="],
+
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
@@ -1721,8 +1723,6 @@
"@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
- "@opencut/web/opencut-wasm": ["opencut-wasm@file:rust/wasm/pkg", {}],
-
"@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
diff --git a/rust/crates/compositor/src/compositor.rs b/rust/crates/compositor/src/compositor.rs
index c0e6ec3b..84bcceae 100644
--- a/rust/crates/compositor/src/compositor.rs
+++ b/rust/crates/compositor/src/compositor.rs
@@ -288,6 +288,59 @@ impl Compositor {
self.textures.remove(id);
}
+ /// Composites all frame items into a texture and returns it.
+ /// Used on backends that cannot surface-render to an arbitrary canvas (e.g. WebGL).
+ pub fn render_frame_to_texture(
+ &mut self,
+ context: &GpuContext,
+ frame: &FrameDescriptor,
+ ) -> Result<wgpu::Texture, CompositorError> {
+ self.texture_pool.recycle_frame();
+ let mut encoder =
+ context
+ .device()
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("compositor-frame-encoder"),
+ });
+ let mut scene = self.create_cleared_texture(
+ context,
+ &mut encoder,
+ frame.width,
+ frame.height,
+ frame.clear.color,
+ );
+
+ for item in &frame.items {
+ match item {
+ FrameItemDescriptor::Layer(layer) => {
+ let layer_texture = self.render_layer(context, &mut encoder, frame, layer)?;
+ scene = self.blend_texture(
+ context,
+ &mut encoder,
+ &scene,
+ &layer_texture,
+ layer.blend_mode,
+ frame.width,
+ frame.height,
+ )?;
+ }
+ FrameItemDescriptor::SceneEffect { effect_pass_groups } => {
+ scene = self.apply_effect_groups(
+ context,
+ &mut encoder,
+ &scene,
+ frame.width,
+ frame.height,
+ effect_pass_groups,
+ )?;
+ }
+ }
+ }
+
+ context.queue().submit([encoder.finish()]);
+ Ok(scene)
+ }
+
pub fn render_frame(
&mut self,
context: &GpuContext,
diff --git a/rust/crates/compositor/src/shaders/layer.wgsl b/rust/crates/compositor/src/shaders/layer.wgsl
index 9e335687..61457d84 100644
--- a/rust/crates/compositor/src/shaders/layer.wgsl
+++ b/rust/crates/compositor/src/shaders/layer.wgsl
@@ -11,6 +11,7 @@ struct LayerUniforms {
opacity: f32,
flip_x: f32,
flip_y: f32,
+ _padding: vec2f,
}
@group(0) @binding(0) var source_texture: texture_2d<f32>;
diff --git a/rust/crates/gpu/Cargo.toml b/rust/crates/gpu/Cargo.toml
index 4ee7571e..ff465324 100644
--- a/rust/crates/gpu/Cargo.toml
+++ b/rust/crates/gpu/Cargo.toml
@@ -10,7 +10,7 @@ wgpu = "29.0.1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2.116"
-web-sys = { version = "0.3.93", features = ["Document", "Window", "HtmlCanvasElement", "OffscreenCanvasRenderingContext2d", "ImageData"] }
+web-sys = { version = "0.3.93", features = ["Document", "Window", "HtmlCanvasElement", "CanvasRenderingContext2d", "OffscreenCanvasRenderingContext2d", "ImageData"] }
[features]
default = []
diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs
index e72a959b..39070ec2 100644
--- a/rust/crates/gpu/src/context.rs
+++ b/rust/crates/gpu/src/context.rs
@@ -40,10 +40,17 @@ pub struct GpuContext {
texture_sampler_bind_group_layout: wgpu::BindGroupLayout,
blit_pipeline: wgpu::RenderPipeline,
supports_external_texture_copies: bool,
+ /// The HTML canvas that the WebGL context is bound to. Only populated on the WebGL
+ /// fallback path. Used by render_texture_via_gl_canvas to output frames on WebGL.
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ gl_canvas: Option<web_sys::HtmlCanvasElement>,
}
impl GpuContext {
pub async fn new() -> Result<Self, GpuError> {
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ let (instance, adapter, device, queue, gl_canvas) = Self::acquire_device().await?;
+ #[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
let (instance, adapter, device, queue) = Self::acquire_device().await?;
let texture_format = if adapter.get_info().backend == wgpu::Backend::Gl {
wgpu::TextureFormat::Rgba8Unorm
@@ -161,9 +168,37 @@ impl GpuContext {
texture_sampler_bind_group_layout,
blit_pipeline,
supports_external_texture_copies,
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ gl_canvas,
})
}
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ async fn acquire_device() -> Result<
+ (
+ wgpu::Instance,
+ wgpu::Adapter,
+ wgpu::Device,
+ wgpu::Queue,
+ Option<web_sys::HtmlCanvasElement>,
+ ),
+ GpuError,
+ > {
+ let instance = wgpu::util::new_instance_with_webgpu_detection(
+ wgpu::InstanceDescriptor::new_without_display_handle(),
+ )
+ .await;
+
+ match Self::try_request_device(&instance, None).await {
+ Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)),
+ Err(_) => {}
+ }
+
+ let (gl_instance, adapter, device, queue, canvas) = Self::try_gl_fallback().await?;
+ Ok((gl_instance, adapter, device, queue, Some(canvas)))
+ }
+
+ #[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
async fn acquire_device()
-> Result<(wgpu::Instance, wgpu::Adapter, wgpu::Device, wgpu::Queue), GpuError> {
let instance = wgpu::util::new_instance_with_webgpu_detection(
@@ -180,8 +215,16 @@ impl GpuContext {
}
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
- async fn try_gl_fallback()
- -> Result<(wgpu::Instance, wgpu::Adapter, wgpu::Device, wgpu::Queue), GpuError> {
+ async fn try_gl_fallback() -> Result<
+ (
+ wgpu::Instance,
+ wgpu::Adapter,
+ wgpu::Device,
+ wgpu::Queue,
+ web_sys::HtmlCanvasElement,
+ ),
+ GpuError,
+ > {
let mut gl_desc = wgpu::InstanceDescriptor::new_without_display_handle();
gl_desc.backends = wgpu::Backends::GL;
gl_desc.display = Some(Box::new(WebDisplay));
@@ -196,11 +239,11 @@ impl GpuContext {
.unchecked_into();
canvas.set_width(1);
canvas.set_height(1);
- let surface = gl_instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
+ let surface = gl_instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))?;
let (adapter, device, queue) =
Self::try_request_device(&gl_instance, Some(&surface)).await?;
- Ok((gl_instance, adapter, device, queue))
+ Ok((gl_instance, adapter, device, queue, canvas))
}
#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
@@ -302,6 +345,13 @@ impl GpuContext {
&self.blit_pipeline
}
+ /// Whether the GPU backend can render to arbitrary canvas surfaces.
+ /// True for WebGPU, false for WebGL which can only surface-render to
+ /// the specific canvas its GL context was originally created on.
+ pub fn supports_surface_rendering(&self) -> bool {
+ self.supports_external_texture_copies
+ }
+
pub fn render_texture_to_surface(
&self,
texture: &wgpu::Texture,
@@ -493,80 +543,129 @@ impl GpuContext {
return self.render_texture_to_surface(texture, &surface, width, height);
}
- self.readback_texture_to_offscreen_canvas(texture, canvas, width, height)
+ self.render_texture_to_offscreen_canvas_via_gl_canvas(texture, canvas, width, height)
}
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
- fn readback_texture_to_offscreen_canvas(
+ fn render_texture_to_offscreen_canvas_via_gl_canvas(
&self,
texture: &wgpu::Texture,
canvas: &wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
) -> Result<(), GpuError> {
- let buffer_size = (width * height * 4) as u64;
- let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
- label: Some("gpu-readback-buffer"),
- size: buffer_size,
- usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
- mapped_at_creation: false,
- });
+ let gl_canvas = self.render_texture_to_gl_canvas_surface(texture, width, height)?;
+
+ let ctx: web_sys::OffscreenCanvasRenderingContext2d = canvas
+ .get_context("2d")
+ .ok()
+ .flatten()
+ .ok_or(GpuError::AdapterUnavailable)?
+ .unchecked_into();
+ ctx.clear_rect(0.0, 0.0, width as f64, height as f64);
+ ctx.draw_image_with_html_canvas_element(gl_canvas, 0.0, 0.0)
+ .map_err(|_| GpuError::AdapterUnavailable)?;
+
+ Ok(())
+ }
+
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ fn render_texture_to_gl_canvas_surface(
+ &self,
+ texture: &wgpu::Texture,
+ width: u32,
+ height: u32,
+ ) -> Result<&web_sys::HtmlCanvasElement, GpuError> {
+ let gl_canvas = self
+ .gl_canvas
+ .as_ref()
+ .ok_or(GpuError::AdapterUnavailable)?;
+
+ gl_canvas.set_width(width);
+ gl_canvas.set_height(height);
+
+ let surface = self
+ .instance
+ .create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?;
+
+ let caps = surface.get_capabilities(&self.adapter);
+ let surface_format = if caps.formats.contains(&self.texture_format) {
+ self.texture_format
+ } else if !caps.formats.is_empty() {
+ caps.formats[0]
+ } else {
+ return Err(GpuError::UnsupportedSurfaceFormat);
+ };
+
+ if surface_format != self.texture_format {
+ return Err(GpuError::UnsupportedSurfaceFormat);
+ }
+
+ let config = wgpu::SurfaceConfiguration {
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+ format: surface_format,
+ width,
+ height,
+ present_mode: wgpu::PresentMode::Fifo,
+ alpha_mode: caps
+ .alpha_modes
+ .first()
+ .copied()
+ .unwrap_or(wgpu::CompositeAlphaMode::Auto),
+ view_formats: vec![],
+ desired_maximum_frame_latency: 2,
+ };
+ surface.configure(&self.device, &config);
+
+ let surface_texture = self.acquire_surface_texture(&surface)?;
+ let surface_view = surface_texture
+ .texture
+ .create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("gpu-readback-encoder"),
+ label: Some("gpu-gl-canvas-blit-encoder"),
});
- encoder.copy_texture_to_buffer(
- wgpu::TexelCopyTextureInfo {
- texture,
- mip_level: 0,
- origin: wgpu::Origin3d::ZERO,
- aspect: wgpu::TextureAspect::All,
- },
- wgpu::TexelCopyBufferInfo {
- buffer: &buffer,
- layout: wgpu::TexelCopyBufferLayout {
- offset: 0,
- bytes_per_row: Some(width * 4),
- rows_per_image: Some(height),
- },
- },
- wgpu::Extent3d {
- width,
- height,
- depth_or_array_layers: 1,
- },
+ self.encode_texture_blit_to_view(
+ &mut encoder,
+ texture,
+ &surface_view,
+ "gpu-gl-canvas-blit",
);
self.queue.submit([encoder.finish()]);
+ surface_texture.present();
- let slice = buffer.slice(..);
- slice.map_async(wgpu::MapMode::Read, |_| {});
- let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
-
- let data = slice.get_mapped_range();
- let mut rgba_bytes = data.to_vec();
- drop(data);
- buffer.unmap();
-
- if self.texture_format == wgpu::TextureFormat::Bgra8Unorm {
- for pixel in rgba_bytes.chunks_exact_mut(4) {
- pixel.swap(0, 2);
- }
- }
+ Ok(gl_canvas)
+ }
- let clamped = wasm_bindgen::Clamped(&rgba_bytes[..]);
- let image_data =
- web_sys::ImageData::new_with_u8_clamped_array_and_sh(clamped, width, height)
- .map_err(|_| GpuError::AdapterUnavailable)?;
+ /// Renders a texture to an arbitrary HTML canvas on the WebGL backend.
+ ///
+ /// WebGL can only surface-render to the canvas its GL context was originally created on.
+ /// This method renders the texture to the GL canvas, then uses drawImage to copy the
+ /// result to the target canvas — avoiding the async buffer readback issue entirely.
+ ///
+ /// The HTML canvas default color space is sRGB, so the surface may report
+ /// `Rgba8UnormSrgb` as its preferred format even though our render textures use
+ /// `Rgba8Unorm`. We explicitly select `Rgba8Unorm` from the surface's supported
+ /// format list (WebGL2 supports both) to avoid the format mismatch.
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ pub fn render_texture_via_gl_canvas(
+ &self,
+ texture: &wgpu::Texture,
+ target_canvas: &web_sys::HtmlCanvasElement,
+ width: u32,
+ height: u32,
+ ) -> Result<(), GpuError> {
+ let gl_canvas = self.render_texture_to_gl_canvas_surface(texture, width, height)?;
- let ctx: web_sys::OffscreenCanvasRenderingContext2d = canvas
+ let ctx: web_sys::CanvasRenderingContext2d = target_canvas
.get_context("2d")
.ok()
.flatten()
.ok_or(GpuError::AdapterUnavailable)?
.unchecked_into();
- ctx.put_image_data(&image_data, 0.0, 0.0)
+ ctx.draw_image_with_html_canvas_element(gl_canvas, 0.0, 0.0)
.map_err(|_| GpuError::AdapterUnavailable)?;
Ok(())
diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml
index 98e9ed30..d8bda107 100644
--- a/rust/wasm/Cargo.toml
+++ b/rust/wasm/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "opencut-wasm"
-version = "0.2.5"
+version = "0.2.8"
edition = "2024"
description = "Shared video editor logic compiled to WebAssembly"
repository = "https://github.com/opencut/opencut"
diff --git a/rust/wasm/src/compositor.rs b/rust/wasm/src/compositor.rs
index 55756a21..126c7468 100644
--- a/rust/wasm/src/compositor.rs
+++ b/rust/wasm/src/compositor.rs
@@ -131,22 +131,41 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
));
};
- let surface = gpu_runtime
- .context
- .instance()
- .create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
- .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ if gpu_runtime.context.supports_surface_rendering() {
+ let surface = gpu_runtime
+ .context
+ .instance()
+ .create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
- runtime
- .compositor
- .render_frame(
- &gpu_runtime.context,
- RenderFrameOptions {
- frame: &frame,
- surface: &surface,
- },
- )
- .map_err(|error| JsValue::from_str(&error.to_string()))
+ runtime
+ .compositor
+ .render_frame(
+ &gpu_runtime.context,
+ RenderFrameOptions {
+ frame: &frame,
+ surface: &surface,
+ },
+ )
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ } else {
+ // WebGL cannot surface-render to an arbitrary canvas element.
+ // Composite to a texture, then blit to the canvas via the 2D context.
+ let texture = runtime
+ .compositor
+ .render_frame_to_texture(&gpu_runtime.context, &frame)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+
+ gpu_runtime
+ .context
+ .render_texture_via_gl_canvas(
+ &texture,
+ &runtime.canvas,
+ frame.width,
+ frame.height,
+ )
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
})
})
}
@@ -171,4 +190,3 @@ fn parse_upload_texture_options(value: JsValue) -> Result<UploadTextureOptions,
height: read_u32_property(&object, "height")?,
})
}
-
diff --git a/rust/wasm/src/gpu.rs b/rust/wasm/src/gpu.rs
index 4e65978c..52c051f0 100644
--- a/rust/wasm/src/gpu.rs
+++ b/rust/wasm/src/gpu.rs
@@ -19,9 +19,27 @@ 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);
+ }));
+ });
+}
+
#[wasm_bindgen(js_name = initializeGpu)]
pub async fn initialize_gpu() -> Result<(), JsValue> {
- console_error_panic_hook::set_once();
+ set_panic_hook();
if GPU_RUNTIME.with(|runtime| runtime.borrow().is_some()) {
return Ok(());