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 source = this.getTestSource({ width: size, height: size }); - if (!source) return; - - 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 targetCtx = targetCanvas.getContext( "2d", ) as CanvasRenderingContext2D | null; - if (targetCtx) { - targetCanvas.width = size; - targetCanvas.height = size; + if (!targetCtx) { + return; + } + + targetCanvas.width = size; + targetCanvas.height = size; + + const source = this.getTestSource({ width: size, height: size }); + if (!source) { + targetCtx.clearRect(0, 0, size, size); + return; + } + + 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, + }); + 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 { + 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; 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, } impl GpuContext { pub async fn new() -> Result { + #[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, + ), + 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,72 +543,18 @@ 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 mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("gpu-readback-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.queue.submit([encoder.finish()]); - - 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); - } - } - - 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)?; + let gl_canvas = self.render_texture_to_gl_canvas_surface(texture, width, height)?; let ctx: web_sys::OffscreenCanvasRenderingContext2d = canvas .get_context("2d") @@ -566,7 +562,110 @@ impl GpuContext { .flatten() .ok_or(GpuError::AdapterUnavailable)? .unchecked_into(); - ctx.put_image_data(&image_data, 0.0, 0.0) + 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-gl-canvas-blit-encoder"), + }); + self.encode_texture_blit_to_view( + &mut encoder, + texture, + &surface_view, + "gpu-gl-canvas-blit", + ); + self.queue.submit([encoder.finish()]); + surface_texture.present(); + + Ok(gl_canvas) + } + + /// 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::CanvasRenderingContext2d = target_canvas + .get_context("2d") + .ok() + .flatten() + .ok_or(GpuError::AdapterUnavailable)? + .unchecked_into(); + 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> = 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(());