diff --git a/.gitignore b/.gitignore index aabdaaf7c..55257c5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ crates/canvas-napi/bench/results/ # Internal notes (local only) crates/canvas-napi/PORTING.md crates/vendor/napi-sys/PATCHED.md + +# The packed @nativescript/linux runtime apps/demo-linux installs (copied in, not committed) +tools/linux/*.tgz diff --git a/apps/demo-linux/.gitignore b/apps/demo-linux/.gitignore new file mode 100644 index 000000000..407ded969 --- /dev/null +++ b/apps/demo-linux/.gitignore @@ -0,0 +1,42 @@ +# NativeScript +hooks/ +node_modules/ +platforms/ + +# NativeScript Template +*.js.map +*.js +!webpack.config.js + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# General +.DS_Store +.AppleDouble +.LSOverride +.idea +.cloud +.project +tmp/ +typings/ + +# misc +npm-debug.log + +# app +!*.d.ts +!src/assets/fontawesome.min.css +/report/ +.nsbuildinfo +/temp/ +/src/tns_modules/ + +# app uses platform specific scss which can inadvertently get renamed which will cause problems +app/app.scss + +package-lock.json diff --git a/apps/demo-linux/nativescript.config.ts b/apps/demo-linux/nativescript.config.ts new file mode 100644 index 000000000..951b86477 --- /dev/null +++ b/apps/demo-linux/nativescript.config.ts @@ -0,0 +1,10 @@ +import { NativeScriptConfig } from '@nativescript/core'; + +export default { + id: 'org.nativescript.canvaslinuxdemo', + appPath: 'src', + appResourcesPath: '../../tools/assets/App_Resources', + cli: { + packageManager: 'npm', + }, +} as NativeScriptConfig; diff --git a/apps/demo-linux/package.json b/apps/demo-linux/package.json new file mode 100644 index 000000000..3ed3d1724 --- /dev/null +++ b/apps/demo-linux/package.json @@ -0,0 +1,21 @@ +{ + "name": "demo-linux", + "main": "./src/app.ts", + "description": "Canvas 2D, WebGL and Web Audio on NativeScript Linux", + "private": true, + "dependencies": { + "@nativescript/audio-context": "file:../../packages/audio-context", + "@nativescript/canvas": "file:../../packages/canvas", + "@nativescript/core": "https://pkg.pr.new/@nativescript/core@11620", + "@nativescript/font-manager": "file:../../node_modules/@nativescript/font-manager" + }, + "devDependencies": { + "@nativescript/linux": "file:../../tools/linux/nativescript-linux-0.1.0-alpha.5.tgz", + "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@11620", + "nativescript": "9.1.2-dev.2026-11-07-42333414597", + "typescript": "~5.9.0" + }, + "scripts": { + "start": "ns run linux --no-hmr" + } +} diff --git a/apps/demo-linux/references.d.ts b/apps/demo-linux/references.d.ts new file mode 100644 index 000000000..e46d153f1 --- /dev/null +++ b/apps/demo-linux/references.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/demo-linux/src/app.ts b/apps/demo-linux/src/app.ts new file mode 100644 index 000000000..7592a89a0 --- /dev/null +++ b/apps/demo-linux/src/app.ts @@ -0,0 +1,162 @@ +import { Application, Button, GridLayout, ItemSpec, Label } from '@nativescript/core'; +import { Canvas } from '@nativescript/canvas'; +import { AudioContext } from '@nativescript/audio-context'; + +function canvasIn(grid: GridLayout, row: number, col: number, onReady: (canvas: Canvas) => void) { + const canvas = new Canvas(); + canvas.style.width = '100%' as any; + canvas.style.height = '100%' as any; + GridLayout.setRow(canvas, row); + GridLayout.setColumn(canvas, col); + grid.addChild(canvas); + // Usable right away; the drawing buffer follows the view's size once laid out. + onReady(canvas); + return canvas; +} + +/** A gradient, shapes, text and an orbiting dot (requestAnimationFrame). */ +function draw2D(canvas: Canvas) { + const ctx = canvas.getContext('2d') as any; + let logged = ''; + const frame = (time: number) => { + const { width, height } = canvas; + if (logged !== `${width}x${height}`) { + logged = `${width}x${height}`; + console.log(`2d: ${logged}`); + } + const gradient = ctx.createLinearGradient(0, 0, width, height); + gradient.addColorStop(0, '#1e3c72'); + gradient.addColorStop(1, '#2a5298'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + + ctx.fillStyle = 'rgba(255, 200, 0, 0.9)'; + ctx.beginPath(); + ctx.arc(width * 0.3, height * 0.5, Math.min(width, height) * 0.25, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = '#ff4f6d'; + ctx.lineWidth = 8; + ctx.strokeRect(width * 0.55, height * 0.25, width * 0.3, height * 0.5); + + const t = time / 1000; + ctx.fillStyle = 'white'; + ctx.beginPath(); + ctx.arc(width * 0.3 + Math.cos(t * 2) * width * 0.15, height * 0.5 + Math.sin(t * 2) * height * 0.3, 10, 0, Math.PI * 2); + ctx.fill(); + + ctx.font = `bold ${Math.round(width * 0.07)}px sans-serif`; + ctx.fillStyle = 'white'; + ctx.fillText('Canvas 2D on GTK 4', 16, height * 0.15); + requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); +} + +/** A clear colour and an RGB triangle. */ +function drawWebGL(canvas: Canvas) { + const gl = canvas.getContext('webgl2') as WebGL2RenderingContext; + if (!gl) { + console.log('webgl2: unavailable'); + return; + } + console.log(`webgl2: ${gl.getParameter(gl.VERSION)} / ${gl.getParameter(gl.RENDERER)}`); + const shader = (type: number, source: string) => { + const s = gl.createShader(type); + gl.shaderSource(s, source); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + console.log('shader:', gl.getShaderInfoLog(s)); + } + return s; + }; + const program = gl.createProgram(); + gl.attachShader( + program, + shader( + gl.VERTEX_SHADER, + `#version 300 es + in vec2 position; in vec3 color; out vec3 vColor; + void main() { vColor = color; gl_Position = vec4(position, 0.0, 1.0); }`, + ), + ); + gl.attachShader( + program, + shader( + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; in vec3 vColor; out vec4 outColor; + void main() { outColor = vec4(vColor, 1.0); }`, + ), + ); + gl.linkProgram(program); + gl.useProgram(program); + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0.8, 1, 0, 0, -0.8, -0.8, 0, 1, 0, 0.8, -0.8, 0, 0, 1]), gl.STATIC_DRAW); + const position = gl.getAttribLocation(program, 'position'); + const color = gl.getAttribLocation(program, 'color'); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 20, 0); + gl.enableVertexAttribArray(color); + gl.vertexAttribPointer(color, 3, gl.FLOAT, false, 20, 8); + const frame = (time: number) => { + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + gl.clearColor(0.1, 0.1, 0.12 + 0.08 * Math.sin(time / 500), 1); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.drawArrays(gl.TRIANGLES, 0, 3); + requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); +} + +let audio: AudioContext | null = null; + +/** A 440 Hz tone, faded in and out over a second. */ +function playTone(status: Label) { + audio ??= new AudioContext(); + const now = audio.currentTime; + const oscillator = audio.createOscillator(); + const gain = audio.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(440, now); + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.3, now + 0.05); + gain.gain.linearRampToValueAtTime(0, now + 1); + oscillator.connect(gain); + gain.connect(audio.destination); + oscillator.start(now); + oscillator.stop(now + 1); + status.text = `AudioContext: ${audio.state}, ${audio.sampleRate} Hz`; + console.log(`audio: ${audio.state} ${audio.sampleRate} Hz, base latency ${audio.baseLatency}`); +} + +Application.run({ + create: () => { + const grid = new GridLayout(); + grid.addRow(new ItemSpec(1, 'star')); + grid.addRow(new ItemSpec(1, 'auto')); + grid.addColumn(new ItemSpec(1, 'star')); + grid.addColumn(new ItemSpec(1, 'star')); + canvasIn(grid, 0, 0, draw2D); + canvasIn(grid, 0, 1, drawWebGL); + + const status = new Label(); + status.text = 'AudioContext: not started'; + status.margin = 12; + GridLayout.setRow(status, 1); + grid.addChild(status); + + const play = new Button(); + play.text = 'Play 440 Hz'; + play.margin = 8; + play.on('tap', () => playTone(status)); + GridLayout.setRow(play, 1); + GridLayout.setColumn(play, 1); + grid.addChild(play); + + // Once on start, so a run without input still plays. + setTimeout(() => playTone(status), 1500); + return grid; + }, +}); diff --git a/apps/demo-linux/tsconfig.json b/apps/demo-linux/tsconfig.json new file mode 100644 index 000000000..0aa378313 --- /dev/null +++ b/apps/demo-linux/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": false, + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "baseUrl": ".", + "paths": { + "~/*": ["src/*"] + }, + "noEmit": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts", "references.d.ts"] +} diff --git a/apps/demo-linux/webpack.config.js b/apps/demo-linux/webpack.config.js new file mode 100644 index 000000000..4adda34fc --- /dev/null +++ b/apps/demo-linux/webpack.config.js @@ -0,0 +1,16 @@ +const webpack = require('@nativescript/webpack'); +const { resolve } = require('path'); + +module.exports = (env) => { + webpack.init(env); + webpack.useConfig('typescript'); + + webpack.chainWebpack((config) => { + // packages/ ship every platform's sources; they were never type-checked together. + config.plugins.delete('ForkTsCheckerWebpackPlugin'); + // The plugins are linked from packages/: keep one @nativescript/core, the app's. + config.resolve.alias.set('@nativescript/core', resolve(__dirname, 'node_modules', '@nativescript', 'core')); + }); + + return webpack.resolveConfig(); +}; diff --git a/apps/demo/nativescript.config.ts b/apps/demo/nativescript.config.ts index 11e5fb0ea..c0001c37e 100644 --- a/apps/demo/nativescript.config.ts +++ b/apps/demo/nativescript.config.ts @@ -8,6 +8,10 @@ export default { markingMode: 'none', discardUncaughtJsExceptions: false, }, + // Light like the Android app theme (Theme.AppCompat.Light), whatever the desktop uses + linux: { + appearance: 'light', + }, appPath: 'src', ios: { discardUncaughtJsExceptions: false, diff --git a/apps/demo/package.json b/apps/demo/package.json index 5dce04007..f06909dbc 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -16,7 +16,7 @@ "@nativescript/canvas-polyfill": "file:../../packages/canvas-polyfill", "@nativescript/canvas-svg": "file:../../packages/canvas-svg", "@nativescript/canvas-three": "file:../../packages/canvas-three", - "@nativescript/core": "https://pkg.pr.new/@nativescript/core@54125e1", + "@nativescript/core": "https://pkg.pr.new/@nativescript/core@11620", "@nativescript/font-manager": "file:../../node_modules/@nativescript/font-manager", "@pixi-essentials/svg": "file:../../node_modules/@pixi-essentials/svg", "babylonjs": "file:../../node_modules/babylonjs", @@ -37,9 +37,11 @@ "@nativescript/android": "9.1.1", "@nativescript/devtools": "~0.0.1", "@nativescript/ios": "9.0.4-next.2026-08-13-31742124335", + "@nativescript/linux": "file:../../tools/linux/nativescript-linux-0.1.0-alpha.5.tgz", "@nativescript/visionos": "~9.0.1", - "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@54125e1", + "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@11620", "@nativescript/windows": "beta", + "nativescript": "9.1.2-dev.2026-11-07-42333414597", "typescript": "~5.6.0" } } diff --git a/apps/demo/src/launch-args.ts b/apps/demo/src/launch-args.ts index 8a4c6dc13..f430a8bf2 100644 --- a/apps/demo/src/launch-args.ts +++ b/apps/demo/src/launch-args.ts @@ -16,13 +16,15 @@ * * echo {"demo":"canvas-spec","suite":"2d"} > %LOCALAPPDATA%\Packages\\LocalState\launch-args.json * + * Linux: after the app folder, e.g. `platforms/linux/demo/bin/demo app --demo=canvas-spec`. + * * Captured at launch rather than read in the page, because the first page * navigates during bootstrap -- before Application.android.foregroundActivity * is assigned. */ import { getFileAccess, knownFolders, path } from '@nativescript/core'; -declare const __WINDOWS__: boolean, NSWinRT: any, Windows: any; +declare const __WINDOWS__: boolean, __LINUX__: boolean, NSWinRT: any, Windows: any; export const launchArgs: { demo?: string; profile?: string; frames?: number; suite?: string; surface?: string } = {}; @@ -40,7 +42,10 @@ export function captureLaunchArgs(androidIntent?: any) { if (__ANDROID__) { captureFromIntent(androidIntent); } else if (__APPLE__) { - captureFromProcessArguments(); + const args = NSProcessInfo.processInfo.arguments; + captureFromArguments(args ? Array.from({ length: args.count }, (_, i) => String(args.objectAtIndex(i))) : []); + } else if (typeof __LINUX__ !== 'undefined' && __LINUX__) { + captureFromArguments((global as any).process?.argv ?? []); } else if (typeof __WINDOWS__ !== 'undefined' && __WINDOWS__) { captureFromLaunchFile(); } @@ -69,13 +74,8 @@ function captureFromIntent(intent: any) { } /** `--demo=canvas-spec`, as simctl and Xcode's scheme arguments pass them. */ -function captureFromProcessArguments() { - const args = NSProcessInfo.processInfo.arguments; - if (!args) { - return; - } - for (let i = 0; i < args.count; i++) { - const arg = String(args.objectAtIndex(i)); +function captureFromArguments(args: string[]) { + for (const arg of args) { const eq = arg.indexOf('='); if (arg.indexOf('--') !== 0 || eq === -1) { continue; diff --git a/apps/demo/webpack.config.js b/apps/demo/webpack.config.js index 6b0c0892d..2dea5caf5 100644 --- a/apps/demo/webpack.config.js +++ b/apps/demo/webpack.config.js @@ -12,6 +12,11 @@ module.exports = (env) => { // shared demo code config.resolve.alias.set('@demo/shared', resolve(__dirname, '..', '..', 'tools', 'demo')); + + if (webpack.Utils.platform.getPlatformName() === 'linux') { + // The plugins are linked from packages/: keep one @nativescript/core, the app's. + config.resolve.alias.set('@nativescript/core', resolve(__dirname, 'node_modules', '@nativescript', 'core')); + } //config.resolve.alias.set('three', 'three/webgpu'); // config.resolve.alias.set('three/tsl', 'three/tsl'); diff --git a/crates/audio-context-napi/Cargo.toml b/crates/audio-context-napi/Cargo.toml index 706cd1465..e9b6f85e2 100644 --- a/crates/audio-context-napi/Cargo.toml +++ b/crates/audio-context-napi/Cargo.toml @@ -3,7 +3,7 @@ name = "audio-context-napi" version = "0.1.0" edition = "2021" publish = false -description = "Node-API binding for @nativescript/audio-context: a Web Audio graph rendered by the web-audio-api crate and played through WASAPI (cpal), for Node-API hosts (NativeScript Windows, Node)." +description = "Node-API binding for @nativescript/audio-context: a Web Audio graph rendered by the web-audio-api crate and played through cpal (WASAPI on Windows, ALSA on Linux), for Node-API hosts (NativeScript Windows and Linux, Node)." [lib] crate-type = ["cdylib"] diff --git a/crates/canvas-2d/src/context/surface_gl.rs b/crates/canvas-2d/src/context/surface_gl.rs index 96022407b..654d20cc5 100644 --- a/crates/canvas-2d/src/context/surface_gl.rs +++ b/crates/canvas-2d/src/context/surface_gl.rs @@ -7,14 +7,14 @@ use skia_safe::{gpu, surfaces, AlphaType, Color, ColorType, ISize, ImageInfo, Pi use std::ffi::c_void; use std::ptr::NonNull; -/// Skia's GL entry points. On Windows GL is ANGLE, so they come from its `eglGetProcAddress`; -/// `new_native` would bind WGL (desktop GL), which is not the context that is current. +/// Skia's GL entry points. On Windows (ANGLE) and Linux (Mesa) GL is EGL's GLES, so they come +/// from its `eglGetProcAddress`; `new_native` would bind WGL / GLX desktop GL. fn gl_interface() -> Option { - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] { Interface::new_load_with(|name| canvas_core::gpu::gl::get_proc_address(name)) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "windows", target_os = "linux")))] { Interface::new_native() } @@ -186,11 +186,14 @@ impl Context { Some(&surface_props), )?; - let (surface, window_surface) = if draws_to_window(&gl_context) { + let (mut surface, window_surface) = if draws_to_window(&gl_context) { with_offscreen(&mut ctx, surface, alpha, color_space.into()) } else { (surface, None) }; + // Linux: a new pbuffer holds whatever was in its memory; a canvas starts transparent. + #[cfg(target_os = "linux")] + surface.canvas().clear(Color::TRANSPARENT); let direct_context = Some(ctx); @@ -393,7 +396,9 @@ impl Context { surface }; - if let Some(surface) = surface { + if let Some(mut surface) = surface { + #[cfg(target_os = "linux")] + surface.canvas().clear(Color::TRANSPARENT); context.window_surface = window_surface; context.direct_context = direct_context; context.surface_state = SurfaceState::None; diff --git a/crates/canvas-c/Cargo.toml b/crates/canvas-c/Cargo.toml index 44d18d0f7..cbe1cceff 100644 --- a/crates/canvas-c/Cargo.toml +++ b/crates/canvas-c/Cargo.toml @@ -67,6 +67,12 @@ wgpu-hal = { workspace = true, features = ["dx12"] } windows = { workspace = true, features = ["Win32_Foundation", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D12", "Win32_System_Com", "Win32_System_Threading"] } skia-safe.workspace = true +# Linux: WebGPU on Vulkan, frames shown through exportable image memory. +[target.'cfg(target_os="linux")'.dependencies] +wgpu-core = { workspace = true, features = ["wgsl", "vulkan"] } +wgpu-hal = { workspace = true, features = ["vulkan"] } +ash = "0.38" + [target.'cfg(target_os="android")'.dependencies] ndk = { version = "0.7.0", features = ["bitmap"] } wgpu-core = { workspace = true, features = ["wgsl", "vulkan"] } diff --git a/crates/canvas-c/src/c2d/context.rs b/crates/canvas-c/src/c2d/context.rs index 4e70ebf38..97a3cc2bd 100644 --- a/crates/canvas-c/src/c2d/context.rs +++ b/crates/canvas-c/src/c2d/context.rs @@ -132,9 +132,9 @@ pub fn resize_gl(context: &mut CanvasRenderingContext2D, width: f32, height: f32 } // A pbuffer keeps its size. - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "linux"))] let color_space = context.surface_data().color_space(); - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "linux"))] if let Some(gl) = context.gl_context.as_mut() { if gl.is_pbuffer() { let mut attr = canvas_core::context_attributes::ContextAttributes::new( @@ -464,6 +464,14 @@ impl CanvasRenderingContext2D { } } + // Linux: ends the frame, so a transferred canvas's view gets it. + #[cfg(all(feature = "gl", target_os = "linux"))] + if self.engine == Engine::GL { + if let Some(context) = self.context.gl_context.as_ref() { + context.swap_buffers(); + } + } + #[cfg(feature = "metal")] if let Some(context) = self.context.metal_context.as_mut() { if context.is_offscreen() { @@ -789,6 +797,26 @@ pub extern "C" fn canvas_native_d3d_simulate_device_removal() -> bool { here || render_thread.unwrap_or(false) } +/// Linux: the EGL context a GL 2D context renders with (null for CPU or threaded contexts). +#[cfg(all(feature = "gl", target_os = "linux"))] +#[no_mangle] +pub extern "C" fn canvas_native_context_get_egl_context( + context: *const CanvasRenderingContext2D, +) -> *mut c_void { + if context.is_null() { + return std::ptr::null_mut(); + } + let context = unsafe { &*context }; + if context.threaded.is_some() { + return std::ptr::null_mut(); + } + context + .context + .gl_context + .as_ref() + .map_or(std::ptr::null_mut(), |gl| gl.egl_context()) +} + #[no_mangle] pub extern "C" fn canvas_native_context_create( width: f32, diff --git a/crates/canvas-c/src/lib.rs b/crates/canvas-c/src/lib.rs index 886e5e3ad..b3558e2af 100644 --- a/crates/canvas-c/src/lib.rs +++ b/crates/canvas-c/src/lib.rs @@ -38,6 +38,8 @@ mod webgl; pub use webgl::*; pub mod offscreen; pub mod impl_test; +#[cfg(target_os = "linux")] +pub mod linux_video_frame; /* Raf */ #[cfg(any(target_os = "android", target_os = "ios", target_os = "visionos", target_os = "tvos"))] #[derive(Clone)] diff --git a/crates/canvas-c/src/linux_video_frame.rs b/crates/canvas-c/src/linux_video_frame.rs new file mode 100644 index 000000000..b511e0a33 --- /dev/null +++ b/crates/canvas-c/src/linux_video_frame.rs @@ -0,0 +1,524 @@ +//! Linux: decoded video frames from @nativescript/canvas-media (`NSCVideoFrame.address`). +//! +//! A GL frame is a texture on GDK's EGL display, which canvases share: WebGL copies it on the +//! GPU through its EGLImage; WebGPU has the producer blit it into an exportable Vulkan image +//! (`webgpu::linux_video_frame`). Other frames are RGBA rows in memory, uploaded once. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::c_void; + +use crate::webgl::WebGLState; + +/// Same layout as `LinuxVideoFrame` in canvas-media's module. +#[repr(C)] +pub struct CanvasLinuxVideoFrame { + pub size: u32, + pub kind: u32, + pub width: u32, + pub height: u32, + pub producer: u64, + pub egl_display: *mut c_void, + pub egl_image: *mut c_void, + pub image_id: u64, + pub data: *const u8, + pub stride: u32, + pub reserved: u32, + pub device_uuid: [u8; 16], + pub copy_to: Option< + unsafe extern "C" fn(*mut CanvasLinuxVideoFrame, *const CanvasLinuxFrameTarget) -> bool, + >, + /// Set by a GL reader: an EGLSync the producer waits for before reusing the texture. + pub release_sync: *mut c_void, + pub user: *mut c_void, +} + +pub const KIND_GL: u32 = 1; +pub const KIND_MEMORY: u32 = 2; + +/// Same layout as `LinuxFrameTarget` in canvas-media's module: an exportable RGBA8 Vulkan image. +#[repr(C)] +pub struct CanvasLinuxFrameTarget { + pub size: u32, + pub memory_fd: i32, + pub id: u64, + pub allocation_size: u64, + pub width: u32, + pub height: u32, + pub ready_fd: i32, + pub release_fd: i32, + pub wait_release: u32, + pub reserved: u32, +} + +pub(crate) unsafe fn frame<'a>(handle: *mut c_void) -> Option<&'a mut CanvasLinuxVideoFrame> { + let frame = unsafe { (handle as *mut CanvasLinuxVideoFrame).as_mut() }?; + (frame.size as usize == std::mem::size_of::() + && frame.width > 0 + && frame.height > 0) + .then_some(frame) +} + +const GL_RGBA8: i32 = 0x8058; +const GL_SRGB8_ALPHA8: i32 = 0x8C43; +const EGL_SYNC_FENCE_KHR: u32 = 0x30F9; + +type ImageTargetTexture = unsafe extern "C" fn(u32, *mut c_void); +type GetCurrentContext = unsafe extern "C" fn() -> *mut c_void; +type CreateSync = unsafe extern "C" fn(*mut c_void, u32, *const i32) -> *mut c_void; +type DestroySync = unsafe extern "C" fn(*mut c_void, *mut c_void) -> u32; + +struct Egl { + target_texture: ImageTargetTexture, + current_context: GetCurrentContext, + create_sync: CreateSync, + destroy_sync: DestroySync, +} + +fn egl() -> Option<&'static Egl> { + static EGL: std::sync::OnceLock> = std::sync::OnceLock::new(); + EGL.get_or_init(|| { + fn load(name: &str) -> Option { + let address = canvas_core::gpu::gl::get_proc_address(name); + (!address.is_null()).then(|| unsafe { std::mem::transmute_copy(&address) }) + } + Some(Egl { + target_texture: load("glEGLImageTargetTexture2DOES")?, + current_context: load("eglGetCurrentContext")?, + create_sync: load("eglCreateSyncKHR")?, + destroy_sync: load("eglDestroySyncKHR")?, + }) + }) + .as_ref() +} + +/// A frame image as a texture of one context, and a framebuffer reading it. +struct Imported { + texture: u32, + framebuffer: u32, + used: u64, +} + +const MAX_IMPORTED: usize = 8; + +thread_local! { + /// By (EGL context, image id). + static IMPORTED: RefCell> = RefCell::new(HashMap::new()); + /// A draw framebuffer per EGL context. + static DRAW: RefCell> = RefCell::new(HashMap::new()); + static USES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The frame image in the current context, imported once. +unsafe fn imported(egl: &Egl, context: usize, frame: &CanvasLinuxVideoFrame) -> Option { + let uses = USES.with(|uses| { + uses.set(uses.get() + 1); + uses.get() + }); + IMPORTED.with(|imported| { + let mut imported = imported.borrow_mut(); + if let Some(entry) = imported.get_mut(&(context, frame.image_id)) { + entry.used = uses; + return Some(entry.framebuffer); + } + if imported.keys().filter(|(c, _)| *c == context).count() >= MAX_IMPORTED { + let oldest = imported + .iter() + .filter(|((c, _), _)| *c == context) + .min_by_key(|(_, entry)| entry.used) + .map(|(key, _)| *key)?; + if let Some(entry) = imported.remove(&oldest) { + unsafe { + gl_bindings::DeleteFramebuffers(1, &entry.framebuffer); + gl_bindings::DeleteTextures(1, &entry.texture); + } + } + } + unsafe { + while gl_bindings::GetError() != gl_bindings::NO_ERROR {} + let mut bound = 0; + gl_bindings::GetIntegerv(gl_bindings::TEXTURE_BINDING_2D, &mut bound); + let mut texture = 0; + gl_bindings::GenTextures(1, &mut texture); + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, texture); + (egl.target_texture)(gl_bindings::TEXTURE_2D, frame.egl_image); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MIN_FILTER, + gl_bindings::NEAREST as i32, + ); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MAG_FILTER, + gl_bindings::NEAREST as i32, + ); + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, bound as u32); + let mut framebuffer = 0; + gl_bindings::GenFramebuffers(1, &mut framebuffer); + if gl_bindings::GetError() != gl_bindings::NO_ERROR { + gl_bindings::DeleteFramebuffers(1, &framebuffer); + gl_bindings::DeleteTextures(1, &texture); + return None; + } + imported.insert( + (context, frame.image_id), + Imported { + texture, + framebuffer, + used: uses, + }, + ); + // Attached on first use, with the read binding saved by the caller. + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, framebuffer); + gl_bindings::FramebufferTexture2D( + gl_bindings::READ_FRAMEBUFFER, + gl_bindings::COLOR_ATTACHMENT0, + gl_bindings::TEXTURE_2D, + texture, + 0, + ); + Some(framebuffer) + } + }) +} + +fn draw_framebuffer(context: usize) -> u32 { + DRAW.with(|draw| { + *draw.borrow_mut().entry(context).or_insert_with(|| { + let mut framebuffer = 0; + unsafe { gl_bindings::GenFramebuffers(1, &mut framebuffer) }; + framebuffer + }) + }) +} + +/// The texture binding a `texImage` target's texture is found at, and its parameter target. +fn binding(target: u32) -> Option<(u32, u32)> { + Some(match target { + gl_bindings::TEXTURE_2D => (gl_bindings::TEXTURE_BINDING_2D, gl_bindings::TEXTURE_2D), + gl_bindings::TEXTURE_CUBE_MAP_POSITIVE_X..=gl_bindings::TEXTURE_CUBE_MAP_NEGATIVE_Z => ( + gl_bindings::TEXTURE_BINDING_CUBE_MAP, + gl_bindings::TEXTURE_CUBE_MAP, + ), + gl_bindings::TEXTURE_3D => (gl_bindings::TEXTURE_BINDING_3D, gl_bindings::TEXTURE_3D), + gl_bindings::TEXTURE_2D_ARRAY => ( + gl_bindings::TEXTURE_BINDING_2D_ARRAY, + gl_bindings::TEXTURE_2D_ARRAY, + ), + _ => return None, + }) +} + +/// Unpack state a frame upload resets, put back after. +struct Unpack([i32; 7]); + +const UNPACK: [u32; 6] = [ + gl_bindings::UNPACK_ROW_LENGTH, + gl_bindings::UNPACK_ALIGNMENT, + gl_bindings::UNPACK_SKIP_ROWS, + gl_bindings::UNPACK_SKIP_PIXELS, + gl_bindings::UNPACK_IMAGE_HEIGHT, + gl_bindings::UNPACK_SKIP_IMAGES, +]; + +impl Unpack { + unsafe fn reset(row_length: i32) -> Self { + let mut saved = [0; 7]; + unsafe { + for (i, name) in UNPACK.iter().enumerate() { + gl_bindings::GetIntegerv(*name, &mut saved[i]); + } + gl_bindings::GetIntegerv(gl_bindings::PIXEL_UNPACK_BUFFER_BINDING, &mut saved[6]); + for name in UNPACK { + gl_bindings::PixelStorei( + name, + if name == gl_bindings::UNPACK_ALIGNMENT { + 4 + } else { + 0 + }, + ); + } + gl_bindings::PixelStorei(gl_bindings::UNPACK_ROW_LENGTH, row_length); + gl_bindings::BindBuffer(gl_bindings::PIXEL_UNPACK_BUFFER, 0); + } + Unpack(saved) + } + + unsafe fn restore(&self) { + unsafe { + for (i, name) in UNPACK.iter().enumerate() { + gl_bindings::PixelStorei(*name, self.0[i]); + } + gl_bindings::BindBuffer(gl_bindings::PIXEL_UNPACK_BUFFER, self.0[6] as u32); + } + } +} + +/// Where a frame goes: `texImage2D/3D` (a 1-deep volume) defines the level unless the texture is +/// immutable, `texSubImage` writes at the offset. +#[derive(Clone, Copy)] +struct Destination { + target: u32, + level: i32, + internalformat: i32, + three_d: bool, + define: bool, + x: i32, + y: i32, + z: i32, +} + +unsafe fn define(dst: &Destination, width: i32, height: i32, pixels: *const c_void) { + unsafe { + let (format, type_) = (gl_bindings::RGBA, gl_bindings::UNSIGNED_BYTE); + match (dst.define, dst.three_d) { + (true, false) => gl_bindings::TexImage2D( + dst.target, + dst.level, + dst.internalformat, + width, + height, + 0, + format, + type_, + pixels, + ), + (true, true) => gl_bindings::TexImage3D( + dst.target, + dst.level, + dst.internalformat, + width, + height, + 1, + 0, + format, + type_, + pixels, + ), + (false, false) => gl_bindings::TexSubImage2D( + dst.target, dst.level, dst.x, dst.y, width, height, format, type_, pixels, + ), + (false, true) => gl_bindings::TexSubImage3D( + dst.target, dst.level, dst.x, dst.y, dst.z, width, height, 1, format, type_, pixels, + ), + } + } +} + +/// `texImage` on an immutable texture (texStorage) only writes it. +unsafe fn immutable(target: u32) -> bool { + let Some((_, parameter_target)) = binding(target) else { + return false; + }; + let mut immutable = 0; + unsafe { + gl_bindings::GetTexParameteriv( + parameter_target, + gl_bindings::TEXTURE_IMMUTABLE_FORMAT, + &mut immutable, + ) + }; + immutable != 0 +} + +/// One upload from memory; flipped rows need a copy. +unsafe fn upload(frame: &CanvasLinuxVideoFrame, dst: &Destination, flip_y: bool) -> bool { + if frame.data.is_null() || frame.stride < frame.width * 4 || frame.stride % 4 != 0 { + return false; + } + let (width, height) = (frame.width as usize, frame.height as usize); + let stride = frame.stride as usize; + let rows = unsafe { std::slice::from_raw_parts(frame.data, stride * (height - 1) + width * 4) }; + let flipped; + let (pixels, row_length) = if flip_y { + let row = width * 4; + let mut out = vec![0u8; row * height]; + for (y, chunk) in out.chunks_exact_mut(row).enumerate() { + let source = (height - 1 - y) * stride; + chunk.copy_from_slice(&rows[source..source + row]); + } + flipped = out; + (flipped.as_ptr(), 0) + } else { + (rows.as_ptr(), (stride / 4) as i32) + }; + unsafe { + let unpack = Unpack::reset(row_length); + define(dst, width as i32, height as i32, pixels as *const c_void); + unpack.restore(); + gl_bindings::GetError() == gl_bindings::NO_ERROR + } +} + +/// A GPU copy from the frame's EGLImage; the producer's texture is released once it ran. +unsafe fn copy(frame: &mut CanvasLinuxVideoFrame, dst: &Destination, flip_y: bool) -> bool { + let Some(egl) = egl() else { + return false; + }; + if frame.egl_image.is_null() || frame.egl_display != canvas_core::gpu::gl::egl_display() { + return false; + } + let context = unsafe { (egl.current_context)() } as usize; + let (Some((binding, _)), true) = (binding(dst.target), context != 0) else { + return false; + }; + unsafe { + while gl_bindings::GetError() != gl_bindings::NO_ERROR {} + let (mut read, mut draw, mut texture) = (0, 0, 0); + gl_bindings::GetIntegerv(gl_bindings::READ_FRAMEBUFFER_BINDING, &mut read); + gl_bindings::GetIntegerv(gl_bindings::DRAW_FRAMEBUFFER_BINDING, &mut draw); + gl_bindings::GetIntegerv(binding, &mut texture); + if texture == 0 { + return false; + } + let scissor = gl_bindings::IsEnabled(gl_bindings::SCISSOR_TEST) != 0; + let restore = || { + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, read as u32); + gl_bindings::BindFramebuffer(gl_bindings::DRAW_FRAMEBUFFER, draw as u32); + if scissor { + gl_bindings::Enable(gl_bindings::SCISSOR_TEST); + } + }; + let Some(source) = imported(egl, context, frame) else { + restore(); + return false; + }; + let (width, height) = (frame.width as i32, frame.height as i32); + if dst.define { + let unpack = Unpack::reset(0); + define(dst, width, height, std::ptr::null()); + unpack.restore(); + } + let target = draw_framebuffer(context); + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, source); + gl_bindings::BindFramebuffer(gl_bindings::DRAW_FRAMEBUFFER, target); + if dst.three_d { + gl_bindings::FramebufferTextureLayer( + gl_bindings::DRAW_FRAMEBUFFER, + gl_bindings::COLOR_ATTACHMENT0, + texture as u32, + dst.level, + dst.z, + ); + } else { + gl_bindings::FramebufferTexture2D( + gl_bindings::DRAW_FRAMEBUFFER, + gl_bindings::COLOR_ATTACHMENT0, + dst.target, + texture as u32, + dst.level, + ); + } + let complete = gl_bindings::CheckFramebufferStatus(gl_bindings::DRAW_FRAMEBUFFER) + == gl_bindings::FRAMEBUFFER_COMPLETE; + if complete { + gl_bindings::Disable(gl_bindings::SCISSOR_TEST); + // Row 0 is the frame's top, as texImage wants it unless UNPACK_FLIP_Y_WEBGL. + let (y0, y1) = if flip_y { + (dst.y + height, dst.y) + } else { + (dst.y, dst.y + height) + }; + gl_bindings::BlitFramebuffer( + 0, + 0, + width, + height, + dst.x, + y0, + dst.x + width, + y1, + gl_bindings::COLOR_BUFFER_BIT, + gl_bindings::NEAREST, + ); + } + gl_bindings::FramebufferTexture2D( + gl_bindings::DRAW_FRAMEBUFFER, + gl_bindings::COLOR_ATTACHMENT0, + gl_bindings::TEXTURE_2D, + 0, + 0, + ); + restore(); + if !complete || gl_bindings::GetError() != gl_bindings::NO_ERROR { + return false; + } + let sync = (egl.create_sync)(frame.egl_display, EGL_SYNC_FENCE_KHR, std::ptr::null()); + gl_bindings::Flush(); + if !frame.release_sync.is_null() { + (egl.destroy_sync)(frame.egl_display, frame.release_sync); + } + frame.release_sync = sync; + true + } +} + +/// WebGL `texImage2D/3D(video)` from a canvas-media frame: `three_d` writes one slice (`zoffset`), +/// `sub` the offsets. The frame is RGBA8: sized RGBA formats are kept, others become RGBA. +/// `false` leaves the caller on its pixel path. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_webgl_tex_image_video_frame( + frame: *mut c_void, + target: u32, + level: i32, + internalformat: i32, + three_d: bool, + sub: bool, + xoffset: i32, + yoffset: i32, + zoffset: i32, + state: *mut WebGLState, +) -> bool { + let (Some(frame), Some(state)) = (unsafe { self::frame(frame) }, unsafe { state.as_ref() }) + else { + return false; + }; + let internalformat = match internalformat { + GL_RGBA8 | GL_SRGB8_ALPHA8 => internalformat, + _ => gl_bindings::RGBA as i32, + }; + let mut dst = Destination { + target, + level, + internalformat, + three_d, + define: !sub, + x: if sub { xoffset } else { 0 }, + y: if sub { yoffset } else { 0 }, + z: if sub || three_d { zoffset } else { 0 }, + }; + // The caller keeps the frame until this returns: run now even on a threaded context. + let frame = frame as *mut CanvasLinuxVideoFrame as usize; + state.sync(move |state| { + state.make_current(); + let flip_y = state.get_flip_y(); + dst.define = dst.define && !unsafe { immutable(dst.target) }; + let frame = unsafe { &mut *(frame as *mut CanvasLinuxVideoFrame) }; + let done = match frame.kind { + KIND_GL => unsafe { copy(frame, &dst, flip_y) }, + KIND_MEMORY => unsafe { upload(frame, &dst, flip_y) }, + _ => false, + }; + log_path("WebGL", frame.kind, done); + done + }) +} + +/// `CANVAS_LOG=info`: which path video frames take, once per change. +pub(crate) fn log_path(api: &'static str, kind: u32, done: bool) { + static LAST: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if last.contains(&(api, kind, done)) { + return; + } + last.retain(|entry| entry.0 != api); + last.push((api, kind, done)); + let path = match (kind, done) { + (KIND_GL, true) => "GPU (shared GL texture)", + (KIND_MEMORY, true) => "one upload from mapped memory", + _ => "not native: the caller's fallback", + }; + log::info!("canvas: {api} video frames: {path}"); +} diff --git a/crates/canvas-c/src/offscreen/linux.rs b/crates/canvas-c/src/offscreen/linux.rs new file mode 100644 index 000000000..8fe40bb53 --- /dev/null +++ b/crates/canvas-c/src/offscreen/linux.rs @@ -0,0 +1,58 @@ +//! Linux: a transferred canvas's view takes frames through a hook the host supplies, called on the +//! thread that renders the context (render thread, WebGL thread or owner) at frame end. + +use std::sync::Arc; + +use canvas_core::gpu::gl::PresentHook; + +use super::CanvasOffscreenSurface; +use crate::{CanvasColorSpace, CanvasRenderingContext2D, WebGLState}; + +pub(super) struct View { + pub(super) hook: PresentHook, +} + +pub(super) fn hook_2d(context: &CanvasRenderingContext2D, hook: PresentHook) { + match context.render_target() { + Some(target) => target.post(move |real| { + if let Some(gl) = real.context.gl_context.as_ref() { + gl.set_present_hook(Some(hook)); + } + }), + None => { + if let Some(gl) = context.context.gl_context.as_ref() { + gl.set_present_hook(Some(hook)); + } + } + } +} + +pub(super) fn hook_webgl(state: &WebGLState, hook: PresentHook) { + if state.is_threaded() { + state.post(move |inner| inner.gl_context().set_present_hook(Some(hook))); + } else { + state.get_inner().gl_context().set_present_hook(Some(hook)); + } +} + +/// UI thread. `hook(width, height)` gets every frame of the context made on the surface. +pub fn canvas_native_offscreen_surface_create_linux( + width: u32, + height: u32, + density: f32, + ppi: f32, + direction: u32, + color_space: CanvasColorSpace, + hook: PresentHook, +) -> *const CanvasOffscreenSurface { + let surface = CanvasOffscreenSurface::new( + width.max(1), + height.max(1), + density, + ppi, + direction, + color_space, + ); + surface.inner().view = Some(View { hook }); + Arc::into_raw(Arc::new(surface)) +} diff --git a/crates/canvas-c/src/offscreen/mod.rs b/crates/canvas-c/src/offscreen/mod.rs index fac0ba99f..abe6fabc7 100644 --- a/crates/canvas-c/src/offscreen/mod.rs +++ b/crates/canvas-c/src/offscreen/mod.rs @@ -19,8 +19,12 @@ use crate::{CanvasColorSpace, CanvasRenderingContext2D, WebGLState}; pub mod android; #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] mod apple; +#[cfg(target_os = "linux")] +mod linux; #[cfg(all(target_os = "windows", feature = "d3d"))] mod windows; +#[cfg(target_os = "linux")] +pub use self::linux::canvas_native_offscreen_surface_create_linux; #[cfg(all(target_os = "windows", feature = "d3d"))] pub use self::windows::*; @@ -105,6 +109,8 @@ struct Inner { view: Option, #[cfg(all(target_os = "windows", feature = "d3d"))] view: Option, + #[cfg(target_os = "linux")] + view: Option, binding: Binding, sink: Option>, } @@ -128,7 +134,14 @@ impl CanvasOffscreenSurface { color_space: color_space.into(), size: Mutex::new((width, height)), inner: Mutex::new(Inner { - #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", all(target_os = "windows", feature = "d3d")))] + #[cfg(any( + target_os = "ios", + target_os = "tvos", + target_os = "visionos", + target_os = "android", + target_os = "linux", + all(target_os = "windows", feature = "d3d") + ))] view: None, binding: Binding::None, sink: None, @@ -322,9 +335,9 @@ impl CanvasOffscreenSurface { if let (Some(handle), Some(window)) = (handle.as_ref(), window) { android::attach_2d(handle, window, width, height); } - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "linux"))] let engine = self.has_view().then_some(CanvasOffscreenEngine::GL); - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "linux")))] let engine = None; self.bind(handle.map_or(Binding::TwoDDirect, Binding::TwoD), engine, alpha); context @@ -334,6 +347,10 @@ impl CanvasOffscreenSurface { viewless_2d(w, h, density, alpha, font_color, ppi, direction, color_space) } }; + #[cfg(target_os = "linux")] + if let Some(hook) = self.linux_hook() { + linux::hook_2d(&context, hook); + } Box::into_raw(Box::new(context)) } @@ -386,6 +403,10 @@ impl CanvasOffscreenSurface { if let (false, Some(window)) = (state.is_null(), window) { android::attach_webgl(unsafe { &*state }, window); } + #[cfg(target_os = "linux")] + if let (false, Some(hook)) = (state.is_null(), self.linux_hook()) { + linux::hook_webgl(unsafe { &*state }, hook); + } let has_view = self.has_view(); self.bind_webgl(state, has_view, a.alpha) } @@ -514,7 +535,13 @@ impl CanvasOffscreenSurface { self.detach_windows_view(); let sink = { let mut inner = self.inner(); - #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android"))] + #[cfg(any( + target_os = "ios", + target_os = "tvos", + target_os = "visionos", + target_os = "android", + target_os = "linux" + ))] { inner.view = None; } @@ -523,6 +550,11 @@ impl CanvasOffscreenSurface { drop(sink); } + #[cfg(target_os = "linux")] + fn linux_hook(&self) -> Option { + self.inner().view.as_ref().map(|view| view.hook.clone()) + } + fn set_sink(&self, sink: Option) { let old = std::mem::replace(&mut self.inner().sink, sink.map(Arc::new)); drop(old); @@ -553,12 +585,12 @@ fn viewless_2d( Context::new_metal_offscreen(width, height, density, 1, alpha, font_color, ppi, direction, color_space), alpha, )); - #[cfg(all(feature = "gl", target_os = "android"))] + #[cfg(all(feature = "gl", any(target_os = "android", target_os = "linux")))] let gpu = Context::new_gl(std::ptr::null_mut(), width, height, density, alpha, font_color, ppi, direction, color_space) .map(|context| CanvasRenderingContext2D::new_gl(context, alpha)); #[cfg(not(any( all(feature = "metal", any(target_os = "ios", target_os = "tvos", target_os = "visionos")), - all(feature = "gl", target_os = "android") + all(feature = "gl", any(target_os = "android", target_os = "linux")) )))] let gpu: Option = None; gpu.unwrap_or_else(|| { @@ -570,9 +602,23 @@ fn viewless_2d( } fn has_view(inner: &Inner) -> bool { - #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", all(target_os = "windows", feature = "d3d")))] + #[cfg(any( + target_os = "ios", + target_os = "tvos", + target_os = "visionos", + target_os = "android", + target_os = "linux", + all(target_os = "windows", feature = "d3d") + ))] let has = inner.view.is_some(); - #[cfg(not(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", all(target_os = "windows", feature = "d3d"))))] + #[cfg(not(any( + target_os = "ios", + target_os = "tvos", + target_os = "visionos", + target_os = "android", + target_os = "linux", + all(target_os = "windows", feature = "d3d") + )))] let has = { let _ = inner; false @@ -584,7 +630,7 @@ fn resize_webgl_offscreen(state: &WebGLState, width: u32, height: u32) { let (width, height) = (width as i32, height as i32); #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] state.post(move |state| state.resize_drawable(width, height)); - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "linux"))] state.post(move |state| { state.make_current(); state.resize_pbuffer(width, height); @@ -593,7 +639,14 @@ fn resize_webgl_offscreen(state: &WebGLState, width: u32, height: u32) { state.post(move |state| { state.resize_texture_surface(width, height); }); - #[cfg(not(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", target_os = "windows")))] + #[cfg(not(any( + target_os = "ios", + target_os = "tvos", + target_os = "visionos", + target_os = "android", + target_os = "windows", + target_os = "linux" + )))] let _ = (state, width, height); } diff --git a/crates/canvas-c/src/webgl/gl.rs b/crates/canvas-c/src/webgl/gl.rs index 7b7f916e7..355b685a5 100644 --- a/crates/canvas-c/src/webgl/gl.rs +++ b/crates/canvas-c/src/webgl/gl.rs @@ -2381,6 +2381,42 @@ pub extern "C" fn canvas_native_webgl_resize_d3d(state: *mut WebGLState, width: true } +/// Linux: resizes (and clears) the pbuffer drawing buffer. +#[cfg(target_os = "linux")] +#[no_mangle] +pub extern "C" fn canvas_native_webgl_resize_pbuffer( + state: *mut WebGLState, + width: i32, + height: i32, +) -> bool { + if state.is_null() { + return false; + } + let state = unsafe { &mut *state }; + let resize = move |inner: &mut canvas_webgl::prelude::WebGLState| { + inner.make_current(); + inner.resize_pbuffer(width, height); + }; + if state.is_threaded() { + state.post(resize); + } else { + resize(state.get_inner_mut()); + } + true +} + +/// Linux: the EGL context a (non-threaded) WebGL context renders with. +#[cfg(target_os = "linux")] +#[no_mangle] +pub extern "C" fn canvas_native_webgl_get_egl_context( + state: *const WebGLState, +) -> *mut std::ffi::c_void { + if state.is_null() || unsafe { &*state }.is_threaded() { + return std::ptr::null_mut(); + } + unsafe { &*state }.get_inner().gl_context().egl_context() +} + /// Ends a frame: presents it where the context is on screen (Windows panels), else flushes. #[no_mangle] pub extern "C" fn canvas_native_webgl_present(state: *mut WebGLState) -> bool { diff --git a/crates/canvas-c/src/webgl/gl2.rs b/crates/canvas-c/src/webgl/gl2.rs index 178cf5a8a..ebc41deb1 100644 --- a/crates/canvas-c/src/webgl/gl2.rs +++ b/crates/canvas-c/src/webgl/gl2.rs @@ -110,7 +110,11 @@ pub extern "C" fn canvas_native_webgl2_bind_transform_feedback( assert!(!state.is_null()); let state = unsafe { &*state }; state.post(move |state| { - canvas_webgl::webgl2::canvas_native_webgl2_bind_sampler(target, transform_feedback, state) + canvas_webgl::webgl2::canvas_native_webgl2_bind_transform_feedback( + target, + transform_feedback, + state, + ) }) } diff --git a/crates/canvas-c/src/webgpu/enums.rs b/crates/canvas-c/src/webgpu/enums.rs index 70dc1fe0f..b01af3d1b 100644 --- a/crates/canvas-c/src/webgpu/enums.rs +++ b/crates/canvas-c/src/webgpu/enums.rs @@ -2005,9 +2005,7 @@ impl Into> for CanvasBindGroupEntry { let texture = unsafe { &*texture }; BindGroupEntry { binding: self.binding, - resource: wgpu_core::binding_model::BindingResource::ExternalTexture( - Arc::clone(&texture.external_texture), - ), + resource: texture.binding_resource(), } } } diff --git a/crates/canvas-c/src/webgpu/gpu.rs b/crates/canvas-c/src/webgpu/gpu.rs index 93e70e6f9..86d4a7202 100644 --- a/crates/canvas-c/src/webgpu/gpu.rs +++ b/crates/canvas-c/src/webgpu/gpu.rs @@ -102,6 +102,9 @@ pub extern "C" fn canvas_native_webgpu_instance_create() -> *const CanvasWebGPUI #[cfg(target_os = "windows")] let backends = wgt::Backends::DX12; + #[cfg(target_os = "linux")] + let backends = wgt::Backends::VULKAN; + let instance = Instance::new( "webgpu", wgt::InstanceDescriptor { @@ -201,6 +204,11 @@ pub unsafe extern "C" fn canvas_native_webgpu_request_adapter( { inner.request_adapter(&opts, wgt::Backends::DX12) } + + #[cfg(target_os = "linux")] + { + inner.request_adapter(&opts, wgt::Backends::VULKAN) + } }; let adapter = adapter_id.map(|adapter| { diff --git a/crates/canvas-c/src/webgpu/gpu_adapter.rs b/crates/canvas-c/src/webgpu/gpu_adapter.rs index 2ef1fabde..6207c78ea 100644 --- a/crates/canvas-c/src/webgpu/gpu_adapter.rs +++ b/crates/canvas-c/src/webgpu/gpu_adapter.rs @@ -87,6 +87,40 @@ pub extern "C" fn canvas_native_webgpu_adapter_request_adapter_info( Arc::into_raw(Arc::new(CanvasGPUAdapterInfo::new(info))) } +/// `Adapter::request_device` with VK_KHR_external_semaphore_fd, which video frames shared with +/// GL synchronize with (`linux_video_frame`). +#[cfg(target_os = "linux")] +fn request_device_sharing_semaphores( + adapter: &Arc, + descriptor: &wgpu_core::device::DeviceDescriptor, +) -> Result< + (Arc, Arc), + wgpu_core::instance::RequestDeviceError, +> { + let Some(hal) = (unsafe { Arc::clone(adapter).as_hal::() }) else { + return adapter.request_device(descriptor); + }; + let mut descriptor = descriptor.clone(); + adapter.validate_device_descriptor(&mut descriptor)?; + let extension = ash::khr::external_semaphore_fd::NAME; + let supported = hal.physical_device_capabilities().supports_extension(extension); + let open = unsafe { + hal.open_with_callback( + descriptor.required_features, + &descriptor.required_limits, + &descriptor.memory_hints, + Some(Box::new(move |args: wgpu_hal::vulkan::CreateDeviceCallbackArgs| { + if supported && !args.extensions.contains(&extension) { + args.extensions.push(extension); + } + })), + ) + } + .map_err(wgpu_core::device::DeviceError::from_hal)?; + drop(hal); + unsafe { adapter.create_device_and_queue_from_hal(open.into(), &descriptor) } +} + #[no_mangle] pub extern "C" fn canvas_native_webgpu_adapter_request_device( adapter: *const CanvasGPUAdapter, @@ -153,7 +187,11 @@ pub extern "C" fn canvas_native_webgpu_adapter_request_device( }; let callback_data = callback_data as *mut c_void; - match adapter_inner.request_device(&descriptor) { + #[cfg(target_os = "linux")] + let device = request_device_sharing_semaphores(&adapter_inner, &descriptor); + #[cfg(not(target_os = "linux"))] + let device = adapter_inner.request_device(&descriptor); + match device { Ok((device, queue)) => { let error_sink = Arc::new(parking_lot::Mutex::new(ErrorSinkRaw::new( DEFAULT_DEVICE_LOST_HANDLER, @@ -170,6 +208,8 @@ pub extern "C" fn canvas_native_webgpu_adapter_request_device( blit: Arc::new(parking_lot::Mutex::new(None)), #[cfg(target_os = "windows")] shared_frames: Default::default(), + #[cfg(target_os = "linux")] + linux_frames: Default::default(), }); let ret = Arc::into_raw(Arc::new(CanvasGPUDevice { diff --git a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs index 5ad7192b9..b910b244a 100644 --- a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs +++ b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs @@ -67,6 +67,9 @@ pub struct CanvasGPUCanvasContext { /// swapchain's DPI / fit transform ours). #[cfg(all(target_os = "windows", feature = "d3d"))] pub(crate) panel: Option, + /// Linux: frames go to the host's view instead of a surface. + #[cfg(target_os = "linux")] + pub(crate) linux: parking_lot::Mutex>>, } impl Drop for CanvasGPUCanvasContext { @@ -267,7 +270,18 @@ fn offscreen_current_texture(context: &CanvasGPUCanvasContext) -> *const CanvasG // present_surface copies it into the read-back texture. let usage = data.usage | wgt::TextureUsages::COPY_SRC; - let texture = { + #[cfg(target_os = "linux")] + let shown = context + .linux + .lock() + .clone() + .and_then(|presenter| presenter.acquire(surface_data)); + #[cfg(not(target_os = "linux"))] + let shown: Option> = None; + + let texture = if let Some(texture) = shown { + texture + } else { let mut offscreen = context.offscreen_texture.lock(); match offscreen.as_ref() { Some(offscreen) @@ -677,6 +691,8 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_create_offscreen( current_texture: Mutex::default(), offscreen_texture: Mutex::default(), last_capabilities: Mutex::default(), + #[cfg(target_os = "linux")] + linux: Default::default(), #[cfg(all(target_os = "windows", feature = "d3d"))] panel: None, })) @@ -707,10 +723,12 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_resize_offscreen( data.previous_configuration.width = width; data.previous_configuration.height = height; - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "linux")))] let format = wgt::TextureFormat::Bgra8Unorm; #[cfg(target_os = "android")] let format = wgt::TextureFormat::Rgba8Unorm; + #[cfg(target_os = "linux")] + let format = data.texture_data.format; let desc = wgt::TextureDescriptor { label: Some(Cow::Borrowed("ContextReadBack")), size: wgt::Extent3d { @@ -779,6 +797,8 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_create( current_texture: Mutex::default(), offscreen_texture: Mutex::default(), last_capabilities: Mutex::default(), + #[cfg(target_os = "linux")] + linux: Default::default(), }; Arc::into_raw(Arc::new(ctx)) @@ -945,6 +965,8 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_create( current_texture: Mutex::default(), offscreen_texture: Mutex::default(), last_capabilities: Mutex::default(), + #[cfg(target_os = "linux")] + linux: Default::default(), }; Arc::into_raw(Arc::new(ctx)) } @@ -1000,6 +1022,8 @@ pub(crate) unsafe fn create_swap_chain_panel( current_texture: Mutex::default(), offscreen_texture: Mutex::default(), last_capabilities: Mutex::default(), + #[cfg(target_os = "linux")] + linux: Default::default(), panel: Some(target), })), Err(cause) => { @@ -1125,6 +1149,8 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_create_uiview( current_texture: Mutex::default(), offscreen_texture: Mutex::default(), last_capabilities: Mutex::default(), + #[cfg(target_os = "linux")] + linux: Default::default(), }; Arc::into_raw(Arc::new(ctx)) @@ -1278,6 +1304,8 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_create_nsview( current_texture: Mutex::default(), offscreen_texture: Mutex::default(), last_capabilities: Mutex::default(), + #[cfg(target_os = "linux")] + linux: Default::default(), }; Arc::into_raw(Arc::new(ctx)) @@ -1704,12 +1732,16 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_configure( } let (read_back, texture_data) = { - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "linux")))] let mut format = wgt::TextureFormat::Bgra8Unorm; #[cfg(any(target_os = "android"))] let mut format = wgt::TextureFormat::Rgba8Unorm; + // Linux: frames are copied into it as they are, whatever the page configured. + #[cfg(target_os = "linux")] + let mut format = config.format; + let desc = wgt::TextureDescriptor { label: Some(Cow::Borrowed("ContextReadBack")), size: wgt::Extent3d { @@ -2030,6 +2062,14 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_present_surface( }; } + #[cfg(target_os = "linux")] + if texture.surface_id.is_none() { + let presenter = context.linux.lock().clone(); + if let (Some(presenter), Some(data)) = (presenter, context.data.lock().as_ref()) { + presenter.present(data); + } + } + let presented = match (texture.surface_id.is_some(), surface_id.as_ref()) { (true, Some(surface)) => surface.present().map(|_| ()), _ => Ok(()), diff --git a/crates/canvas-c/src/webgpu/gpu_device.rs b/crates/canvas-c/src/webgpu/gpu_device.rs index a1322d631..e284e32bb 100644 --- a/crates/canvas-c/src/webgpu/gpu_device.rs +++ b/crates/canvas-c/src/webgpu/gpu_device.rs @@ -310,10 +310,7 @@ impl CanvasGPUDevice { wgpu_core::binding_model::BindingResource::TextureView(Arc::clone(&view.texture_view)) } CanvasBindGroupEntryResource::ExternalTexture(texture) => { - let texture = unsafe { &**texture }; - wgpu_core::binding_model::BindingResource::ExternalTexture(Arc::clone( - &texture.external_texture, - )) + unsafe { &**texture }.binding_resource() } }, }) @@ -338,9 +335,21 @@ impl CanvasGPUDevice { label: Option>, entries: &[CanvasBindGroupLayoutEntry], ) -> *const CanvasGPUBindGroupLayout { + let emulated = !self.device.features().contains(wgt::Features::EXTERNAL_TEXTURE); let wgpu_entries = entries .iter() - .map(|entry| (*entry).into()) + .map(|entry| { + let mut entry: wgt::BindGroupLayoutEntry = (*entry).into(); + if emulated && entry.ty == wgt::BindingType::ExternalTexture { + // As `lower_external_textures` declares it. + entry.ty = wgt::BindingType::Texture { + sample_type: wgt::TextureSampleType::Float { filterable: true }, + view_dimension: wgt::TextureViewDimension::D2, + multisampled: false, + }; + } + entry + }) .collect::>(); let desc = wgpu_core::binding_model::BindGroupLayoutDescriptor { @@ -1005,9 +1014,17 @@ pub extern "C" fn canvas_native_webgpu_device_create_shader_module( let src = unsafe { CStr::from_ptr(source) }; let src = src.to_str().unwrap(); - let source = wgpu_core::pipeline::ShaderModuleSource::Wgsl(Cow::Owned(src.to_string())); - let device = unsafe { &*device }; + let lowered = (!device + .device + .features() + .contains(wgt::Features::EXTERNAL_TEXTURE)) + .then(|| crate::webgpu::gpu_external_texture::lower_external_textures(src)) + .flatten(); + let source = wgpu_core::pipeline::ShaderModuleSource::Wgsl(Cow::Owned( + lowered.unwrap_or_else(|| src.to_string()), + )); + let desc = wgpu_core::pipeline::ShaderModuleDescriptor { label, runtime_checks: Default::default(), @@ -1015,8 +1032,21 @@ pub extern "C" fn canvas_native_webgpu_device_create_shader_module( let module = device.device.create_shader_module(&desc, source); // Messages hang off the module now instead of arriving with an error. - let msgs: Vec = module - .compilation_info() + let info = module.compilation_info(); + // As browsers do: the reason a module is invalid, in the console. + for message in info + .messages + .iter() + .filter(|m| m.message_type == wgt::CompilationMessageType::Error) + { + let line = message.location.map_or(0, |location| location.line_number); + log::error!( + "WGSL error in shader module '{}' (line {line}): {}", + desc.label.as_deref().unwrap_or(""), + message.message + ); + } + let msgs: Vec = info .messages .iter() .map(|message| { diff --git a/crates/canvas-c/src/webgpu/gpu_external_texture.rs b/crates/canvas-c/src/webgpu/gpu_external_texture.rs index 961686182..3cc6731cb 100644 --- a/crates/canvas-c/src/webgpu/gpu_external_texture.rs +++ b/crates/canvas-c/src/webgpu/gpu_external_texture.rs @@ -1,5 +1,7 @@ //! `GPUDevice.importExternalTexture`. The decoder already outputs RGB, so the frame is one -//! RGBA plane with identity conversion. Null where the backend lacks `EXTERNAL_TEXTURE`. +//! RGBA plane with identity conversion. Where the backend lacks `EXTERNAL_TEXTURE` (Vulkan) the +//! plane binds as a `texture_2d`, which shader modules use in place of `texture_external` +//! (`lower_external_textures`). use std::borrow::Cow; use std::os::raw::{c_char, c_void}; @@ -11,7 +13,84 @@ use super::gpu_device::CanvasGPUDevice; pub struct CanvasGPUExternalTexture { pub(crate) label: Option>, - pub(crate) external_texture: Arc, + /// None when emulated. + pub(crate) external_texture: Option>, + pub(crate) plane: Arc, +} + +impl CanvasGPUExternalTexture { + pub(crate) fn binding_resource(&self) -> wgpu_core::binding_model::BindingResource<'static> { + match &self.external_texture { + Some(texture) => { + wgpu_core::binding_model::BindingResource::ExternalTexture(Arc::clone(texture)) + } + None => wgpu_core::binding_model::BindingResource::TextureView(Arc::clone(&self.plane)), + } + } +} + +/// Without `EXTERNAL_TEXTURE`: `texture_external` becomes `texture_2d` and its 2-argument +/// `textureLoad`s get level 0 (sampling and `textureDimensions` read the same on both). None +/// when there is nothing to change. +pub(crate) fn lower_external_textures(source: &str) -> Option { + const EXTERNAL: &str = "texture_external"; + if !source.contains(EXTERNAL) { + return None; + } + let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_'; + // Names declared `name: texture_external` (globals and parameters). + let mut names = Vec::new(); + let mut search = 0; + while let Some(found) = source[search..].find(EXTERNAL) { + let at = search + found; + search = at + EXTERNAL.len(); + let before = source[..at].trim_end(); + if let Some(before) = before.strip_suffix(':') { + let before = before.trim_end(); + let start = before + .char_indices() + .rev() + .find(|(_, c)| !is_ident(*c)) + .map_or(0, |(i, c)| i + c.len_utf8()); + if start < before.len() { + names.push(&before[start..]); + } + } + } + let mut out = source.replace(EXTERNAL, "texture_2d"); + const LOAD: &str = "textureLoad("; + let mut search = 0; + while let Some(found) = out[search..].find(LOAD) { + let open = search + found + LOAD.len(); + search = open; + let first = out[open..] + .trim_start() + .split(|c: char| !is_ident(c)) + .next() + .unwrap_or(""); + if !names.contains(&first) { + continue; + } + let (mut depth, mut commas, mut close) = (1, 0, None); + for (i, c) in out[open..].char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => { + depth -= 1; + if depth == 0 { + close = Some(open + i); + break; + } + } + ',' if depth == 1 => commas += 1, + _ => {} + } + } + if let (Some(close), 1) = (close, commas) { + out.insert_str(close, ", 0"); + } + } + Some(out) } /// Identity affine transform in wgpu's column-major 3x2 layout. @@ -48,17 +127,16 @@ pub unsafe extern "C" fn canvas_native_webgpu_device_import_external_texture( let device = &*device; - if !device + let emulated = !device .device .features() - .contains(wgt::Features::EXTERNAL_TEXTURE) - { - return std::ptr::null(); - } + .contains(wgt::Features::EXTERNAL_TEXTURE); #[cfg(target_os = "windows")] let plane = super::gpu_shared_frame::import_external_plane(device, native_texture, width, height); - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] + let plane = super::linux_video_frame::import_external_plane(device, native_texture, width, height); + #[cfg(not(any(target_os = "windows", target_os = "linux")))] let plane = super::gpu_native_texture::import_platform_texture(&device.device, native_texture, width, height); let Some(plane) = plane else { return std::ptr::null(); @@ -84,13 +162,13 @@ pub unsafe extern "C" fn canvas_native_webgpu_device_import_external_texture( load_transform: IDENTITY_TRANSFORM, }; - let external_texture = device - .device - .create_external_texture(&descriptor, &[plane_view]); + let external_texture = + (!emulated).then(|| device.device.create_external_texture(&descriptor, &[Arc::clone(&plane_view)])); Arc::into_raw(Arc::new(CanvasGPUExternalTexture { label, external_texture, + plane: plane_view, })) } diff --git a/crates/canvas-c/src/webgpu/gpu_native_texture.rs b/crates/canvas-c/src/webgpu/gpu_native_texture.rs index af06d9c9c..692b16c1f 100644 --- a/crates/canvas-c/src/webgpu/gpu_native_texture.rs +++ b/crates/canvas-c/src/webgpu/gpu_native_texture.rs @@ -340,7 +340,7 @@ pub(crate) unsafe fn import_platform_texture( target_os = "tvos", target_os = "android" )))] -#[allow(dead_code)] // Windows imports through gpu_shared_frame. +#[allow(dead_code)] // Windows and Linux import through gpu_shared_frame / linux_video_frame. pub(crate) unsafe fn import_platform_texture( _device: &Arc, _handle: *mut c_void, @@ -367,9 +367,21 @@ unsafe fn blit_frame_into_texture( ) -> bool { let device = &queue.device_id; + // Checked before importing: a staged frame must be read by the very next submit. + if !(*destination.texture) + .texture + .descriptor() + .usage + .contains(wgt::TextureUsages::RENDER_ATTACHMENT) + { + return false; + } + #[cfg(target_os = "windows")] let source_texture = super::gpu_shared_frame::import_and_stage(queue, handle, frame_width, frame_height); - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] + let source_texture = super::linux_video_frame::import_and_stage(queue, handle, frame_width, frame_height); + #[cfg(not(any(target_os = "windows", target_os = "linux")))] let source_texture = import_platform_texture(device, handle, frame_width, frame_height); let Some(source_texture) = source_texture else { return false; diff --git a/crates/canvas-c/src/webgpu/gpu_queue.rs b/crates/canvas-c/src/webgpu/gpu_queue.rs index 51a1ec4f4..2770ae824 100644 --- a/crates/canvas-c/src/webgpu/gpu_queue.rs +++ b/crates/canvas-c/src/webgpu/gpu_queue.rs @@ -34,6 +34,9 @@ pub struct CanvasGPUQueue { #[cfg(target_os = "windows")] pub(crate) shared_frames: Arc>, + #[cfg(target_os = "linux")] + pub(crate) linux_frames: + Arc>, } unsafe impl Send for CanvasGPUQueue {} diff --git a/crates/canvas-c/src/webgpu/linux_frames.rs b/crates/canvas-c/src/webgpu/linux_frames.rs new file mode 100644 index 000000000..5a92fdd47 --- /dev/null +++ b/crates/canvas-c/src/webgpu/linux_frames.rs @@ -0,0 +1,350 @@ +//! Linux: a WebGPU canvas shown by GTK. `getCurrentTexture()` hands out Vulkan images whose +//! memory is exportable (`VK_KHR_external_memory_fd`); at present the host imports that memory +//! into GDK's GL context (`GL_EXT_memory_object_fd`) and shows it, so frames stay on the GPU. + +use std::borrow::Cow; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use ash::vk; + +use super::gpu_canvas_context::{CanvasGPUCanvasContext, SurfaceData}; + +/// A presented frame. `fd` is the image memory's OPAQUE_FD the first time an image is shown (the +/// receiver owns it), -1 after: images are identified by `image`. +pub struct LinuxGpuFrame { + pub image: u64, + pub fd: i32, + pub allocation_size: u64, + pub width: u32, + pub height: u32, + /// The bytes are B, G, R, A. + pub bgra: bool, + /// Set until the receiver stops showing the frame. + pub busy: Arc, + /// Images gone since the last frame. + pub forget: Vec, +} + +pub type LinuxFrameSink = Arc; + +struct Image { + id: u64, + texture: Arc, + width: u32, + height: u32, + format: wgt::TextureFormat, + usage: wgt::TextureUsages, + allocation_size: u64, + /// Taken by the first present. + fd: Option, + busy: Arc, +} + +pub struct LinuxPresenter { + sink: LinuxFrameSink, + images: parking_lot::Mutex>, + current: parking_lot::Mutex>, + forget: parking_lot::Mutex>, +} + +const MAX_IMAGES: usize = 3; +static NEXT_IMAGE: AtomicU64 = AtomicU64::new(1); + +impl LinuxPresenter { + pub fn new(sink: LinuxFrameSink) -> Arc { + Arc::new(Self { + sink, + images: Default::default(), + current: Default::default(), + forget: Default::default(), + }) + } + + /// A free image for the configured texture, made when needed. + pub(crate) fn acquire(&self, data: &SurfaceData) -> Option> { + let t = data.texture_data; + let usage = t.usage | wgt::TextureUsages::COPY_SRC; + let fits = |image: &Image| { + (image.width, image.height, image.format, image.usage) + == (t.size.width, t.size.height, t.format, usage) + }; + let mut images = self.images.lock(); + { + let mut forget = self.forget.lock(); + images.retain(|image| { + let keep = fits(image) || image.busy.load(Ordering::Acquire); + if !keep { + forget.push(image.id); + } + keep + }); + } + let image = match images + .iter() + .position(|image| fits(image) && !image.busy.load(Ordering::Acquire)) + { + Some(index) => &images[index], + None if images.iter().filter(|image| fits(image)).count() < MAX_IMAGES => { + let image = unsafe { create_image(data, usage) }?; + images.push(image); + images.last()? + } + // The receiver holds them all: draw into the oldest anyway rather than stall. + None => images.iter().find(|image| fits(image))?, + }; + *self.current.lock() = Some(image.id); + Some(Arc::clone(&image.texture)) + } + + /// After the page's last submit for the frame: waits for the GPU, then hands the image over. + pub(crate) fn present(&self, data: &SurfaceData) { + let Some(current) = self.current.lock().take() else { + return; + }; + // GTK samples it in another context (and API): it must be complete. + let _ = data.device.device.poll(wgt::PollType::Wait { + submission_index: None, + timeout: None, + }); + let frame = { + let mut images = self.images.lock(); + let Some(image) = images.iter_mut().find(|image| image.id == current) else { + return; + }; + image.busy.store(true, Ordering::Release); + LinuxGpuFrame { + image: image.id, + fd: image.fd.take().unwrap_or(-1), + allocation_size: image.allocation_size, + width: image.width, + height: image.height, + bgra: matches!( + image.format, + wgt::TextureFormat::Bgra8Unorm | wgt::TextureFormat::Bgra8UnormSrgb + ), + busy: Arc::clone(&image.busy), + forget: std::mem::take(&mut *self.forget.lock()), + } + }; + (self.sink)(frame); + } +} + +fn vk_format(format: wgt::TextureFormat) -> Option { + Some(match format { + wgt::TextureFormat::Bgra8Unorm => vk::Format::B8G8R8A8_UNORM, + wgt::TextureFormat::Bgra8UnormSrgb => vk::Format::B8G8R8A8_SRGB, + wgt::TextureFormat::Rgba8Unorm => vk::Format::R8G8B8A8_UNORM, + wgt::TextureFormat::Rgba8UnormSrgb => vk::Format::R8G8B8A8_SRGB, + _ => return None, + }) +} + +fn usages(usage: wgt::TextureUsages) -> (vk::ImageUsageFlags, wgt::TextureUses) { + let mut vk_usage = vk::ImageUsageFlags::empty(); + let mut uses = wgt::TextureUses::empty(); + if usage.contains(wgt::TextureUsages::COPY_SRC) { + vk_usage |= vk::ImageUsageFlags::TRANSFER_SRC; + uses |= wgt::TextureUses::COPY_SRC; + } + if usage.contains(wgt::TextureUsages::COPY_DST) { + vk_usage |= vk::ImageUsageFlags::TRANSFER_DST; + uses |= wgt::TextureUses::COPY_DST; + } + if usage.contains(wgt::TextureUsages::TEXTURE_BINDING) { + vk_usage |= vk::ImageUsageFlags::SAMPLED; + uses |= wgt::TextureUses::RESOURCE; + } + if usage.contains(wgt::TextureUsages::STORAGE_BINDING) { + vk_usage |= vk::ImageUsageFlags::STORAGE; + uses |= wgt::TextureUses::STORAGE_READ_WRITE; + } + if usage.contains(wgt::TextureUsages::RENDER_ATTACHMENT) { + vk_usage |= vk::ImageUsageFlags::COLOR_ATTACHMENT; + uses |= wgt::TextureUses::COLOR_TARGET; + } + (vk_usage, uses) +} + +/// A dedicated, exportable allocation behind a 2D image, wrapped as a wgpu texture. +unsafe fn create_image(data: &SurfaceData, usage: wgt::TextureUsages) -> Option { + let t = data.texture_data; + let (texture, fd, allocation_size) = unsafe { + create_exportable_texture( + &data.device.device, + t.size, + t.format, + usage, + data.previous_configuration.view_formats.clone(), + wgt::TextureUses::UNINITIALIZED, + "LinuxCanvasFrame", + ) + }?; + Some(Image { + id: NEXT_IMAGE.fetch_add(1, Ordering::Relaxed), + texture, + width: t.size.width, + height: t.size.height, + format: t.format, + usage, + allocation_size, + fd: Some(fd), + busy: Arc::new(AtomicBool::new(false)), + }) +} + +/// A 2D image on a dedicated allocation exported as an OPAQUE_FD (the caller owns the fd), as a +/// wgpu texture in the `initial` state. Wrapped images have no clear mode: they count as +/// initialized. +pub(crate) unsafe fn create_exportable_texture( + device: &Arc, + size: wgt::Extent3d, + format: wgt::TextureFormat, + usage: wgt::TextureUsages, + view_formats: Vec, + initial: wgt::TextureUses, + label: &'static str, +) -> Option<(Arc, i32, u64)> { + let vk_format = vk_format(format)?; + let hal = unsafe { Arc::clone(device).as_hal::() }?; + if !hal + .enabled_device_extensions() + .contains(&ash::khr::external_memory_fd::NAME) + { + log::error!("canvas: sharing WebGPU images needs VK_KHR_external_memory_fd"); + return None; + } + let raw = hal.raw_device().clone(); + let instance = hal.shared_instance().raw_instance(); + let (vk_usage, uses) = usages(usage); + let mut external = vk::ExternalMemoryImageCreateInfo::default() + .handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD); + let info = vk::ImageCreateInfo::default() + .push_next(&mut external) + .flags(if view_formats.is_empty() { + vk::ImageCreateFlags::empty() + } else { + vk::ImageCreateFlags::MUTABLE_FORMAT + }) + .image_type(vk::ImageType::TYPE_2D) + .format(vk_format) + .extent(vk::Extent3D { + width: size.width, + height: size.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk_usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED); + let image = unsafe { raw.create_image(&info, None) }.ok()?; + let requirements = unsafe { raw.get_image_memory_requirements(image) }; + let properties = + unsafe { instance.get_physical_device_memory_properties(hal.raw_physical_device()) }; + let Some(memory_type) = properties + .memory_types_as_slice() + .iter() + .enumerate() + .position(|(i, ty)| { + requirements.memory_type_bits & (1 << i) != 0 + && ty + .property_flags + .contains(vk::MemoryPropertyFlags::DEVICE_LOCAL) + }) + else { + unsafe { raw.destroy_image(image, None) }; + return None; + }; + let mut export = vk::ExportMemoryAllocateInfo::default() + .handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD); + let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image); + let allocate = vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_type as u32) + .push_next(&mut export) + .push_next(&mut dedicated); + let memory = match unsafe { raw.allocate_memory(&allocate, None) } { + Ok(memory) => memory, + Err(_) => { + unsafe { raw.destroy_image(image, None) }; + return None; + } + }; + let release = { + let raw = raw.clone(); + move || unsafe { + raw.destroy_image(image, None); + raw.free_memory(memory, None); + } + }; + if unsafe { raw.bind_image_memory(image, memory, 0) }.is_err() { + release(); + return None; + } + let fd_fn = ash::khr::external_memory_fd::Device::new(instance, &raw); + let fd = match unsafe { + fd_fn.get_memory_fd( + &vk::MemoryGetFdInfoKHR::default() + .memory(memory) + .handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD), + ) + } { + Ok(fd) => fd, + Err(_) => { + release(); + return None; + } + }; + let hal_desc = wgpu_hal::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgt::TextureDimension::D2, + format, + usage: uses, + memory_flags: wgpu_hal::MemoryFlags::empty(), + view_formats: view_formats.clone(), + }; + let hal_texture = unsafe { + hal.texture_from_raw( + image, + &hal_desc, + Some(Box::new(release)), + wgpu_hal::vulkan::TextureMemory::External, + ) + }; + drop(hal); + let desc = wgpu_core::resource::TextureDescriptor { + label: Some(Cow::Borrowed(label)), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgt::TextureDimension::D2, + format, + usage, + view_formats, + }; + let (texture, error) = + unsafe { device.create_texture_from_hal(Box::new(hal_texture), &desc, initial, true) }; + if let Some(error) = error { + log::error!("canvas: could not wrap an exportable image: {error:?}"); + unsafe { libc::close(fd) }; + return None; + } + Some((texture, fd, requirements.size)) +} + +/// UI thread: shows the context's frames through `sink` (Linux hosts). +pub fn canvas_native_webgpu_context_set_linux_sink( + context: *const CanvasGPUCanvasContext, + sink: Option, +) { + if let Some(context) = unsafe { context.as_ref() } { + *context.linux.lock() = sink.map(LinuxPresenter::new); + } +} diff --git a/crates/canvas-c/src/webgpu/linux_video_frame.rs b/crates/canvas-c/src/webgpu/linux_video_frame.rs new file mode 100644 index 000000000..b76919833 --- /dev/null +++ b/crates/canvas-c/src/webgpu/linux_video_frame.rs @@ -0,0 +1,407 @@ +//! Linux: canvas-media frames (`CanvasLinuxVideoFrame`) as wgpu textures. +//! +//! A GL frame is blitted by its producer into an exportable image of ours (one per video, +//! reused). As with Windows' fences, the submit reading it waits for the producer's `ready` +//! semaphore and signals `release`, which the producer waits for before writing it again. Without +//! VK_KHR_external_semaphore_fd the producer finishes its blit and we wait for our reads instead. +//! Memory frames are written into a texture of ours: one upload. + +use std::borrow::Cow; +use std::os::raw::c_void; +use std::sync::Arc; + +use ash::vk; + +use super::gpu_device::CanvasGPUDevice; +use super::gpu_queue::CanvasGPUQueue; +use crate::linux_video_frame::{CanvasLinuxFrameTarget, KIND_GL, KIND_MEMORY}; + +struct SharedImage { + producer: u64, + id: u64, + width: u32, + height: u32, + texture: Arc, + allocation_size: u64, + /// Handed over with the first copy. + fds: Option<(i32, i32, i32)>, + semaphores: Option<(vk::Semaphore, vk::Semaphore)>, + raw: ash::Device, + release_pending: bool, + read: bool, +} + +impl Drop for SharedImage { + fn drop(&mut self) { + unsafe { + if let Some((memory, ready, release)) = self.fds.take() { + for fd in [memory, ready, release] { + if fd >= 0 { + libc::close(fd); + } + } + } + if let Some((ready, release)) = self.semaphores.take() { + self.raw.destroy_semaphore(ready, None); + self.raw.destroy_semaphore(release, None); + } + } + } +} + +#[derive(Default)] +pub struct LinuxFrameCache { + images: Vec, + uploads: Vec>, + /// The device's UUID and whether it exports semaphores. + device: Option>, +} + +const MAX_IMAGES: usize = 4; +static NEXT_IMAGE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +unsafe fn device_info(device: &Arc) -> Option<([u8; 16], bool)> { + let hal = unsafe { Arc::clone(device).as_hal::() }?; + let instance = hal.shared_instance().raw_instance(); + let mut id = vk::PhysicalDeviceIDProperties::default(); + let mut properties = vk::PhysicalDeviceProperties2::default().push_next(&mut id); + unsafe { instance.get_physical_device_properties2(hal.raw_physical_device(), &mut properties) }; + let semaphores = hal + .enabled_device_extensions() + .contains(&ash::khr::external_semaphore_fd::NAME); + log::info!("canvas: WebGPU video frames from GL synchronize with semaphores: {semaphores}"); + Some((id.device_uuid, semaphores)) +} + +impl LinuxFrameCache { + fn device(&mut self, device: &Arc) -> Option<([u8; 16], bool)> { + *self + .device + .get_or_insert_with(|| unsafe { device_info(device) }) + } +} + +/// Two exportable binary semaphores and their OPAQUE_FDs. +unsafe fn semaphores( + device: &Arc, +) -> Option<((vk::Semaphore, vk::Semaphore), (i32, i32))> { + let hal = unsafe { Arc::clone(device).as_hal::() }?; + let raw = hal.raw_device(); + let fd_fn = + ash::khr::external_semaphore_fd::Device::new(hal.shared_instance().raw_instance(), raw); + let mut made = Vec::with_capacity(2); + for _ in 0..2 { + let mut export = vk::ExportSemaphoreCreateInfo::default() + .handle_types(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD); + let info = vk::SemaphoreCreateInfo::default().push_next(&mut export); + let Ok(semaphore) = (unsafe { raw.create_semaphore(&info, None) }) else { + break; + }; + let fd = unsafe { + fd_fn.get_semaphore_fd( + &vk::SemaphoreGetFdInfoKHR::default() + .semaphore(semaphore) + .handle_type(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD), + ) + }; + match fd { + Ok(fd) => made.push((semaphore, fd)), + Err(_) => { + unsafe { raw.destroy_semaphore(semaphore, None) }; + break; + } + } + } + if made.len() != 2 { + for (semaphore, fd) in made { + unsafe { + raw.destroy_semaphore(semaphore, None); + libc::close(fd); + } + } + return None; + } + Some(((made[0].0, made[1].0), (made[0].1, made[1].1))) +} + +unsafe fn create_shared( + device: &Arc, + producer: u64, + width: u32, + height: u32, + with_semaphores: bool, +) -> Option { + let size = wgt::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + // GL leaves it in SHADER_READ_ONLY (its semaphore signal says so): RESOURCE from the start. + let (texture, fd, allocation_size) = unsafe { + super::linux_frames::create_exportable_texture( + device, + size, + wgt::TextureFormat::Rgba8Unorm, + wgt::TextureUsages::TEXTURE_BINDING + | wgt::TextureUsages::RENDER_ATTACHMENT + | wgt::TextureUsages::COPY_SRC + | wgt::TextureUsages::COPY_DST, + vec![], + wgt::TextureUses::RESOURCE, + "videoBlit:SharedFrame", + ) + }?; + let (semaphores, (ready_fd, release_fd)) = match with_semaphores { + true => match unsafe { self::semaphores(device) } { + Some((semaphores, fds)) => (Some(semaphores), fds), + None => (None, (-1, -1)), + }, + false => (None, (-1, -1)), + }; + let raw = unsafe { Arc::clone(device).as_hal::() }? + .raw_device() + .clone(); + Some(SharedImage { + producer, + id: NEXT_IMAGE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + width, + height, + texture, + allocation_size, + fds: Some((fd, ready_fd, release_fd)), + semaphores, + raw, + release_pending: false, + read: false, + }) +} + +fn wait_idle(device: &Arc) { + let _ = device.poll(wgt::PollType::Wait { + submission_index: None, + timeout: None, + }); +} + +unsafe fn shared( + queue: &CanvasGPUQueue, + frame: &mut crate::linux_video_frame::CanvasLinuxVideoFrame, +) -> Option> { + let copy_to = frame.copy_to?; + let device = &queue.device_id; + let mut cache = queue.linux_frames.lock(); + let (uuid, with_semaphores) = cache.device(device)?; + if uuid != frame.device_uuid { + return None; + } + let (width, height) = (frame.width, frame.height); + let index = match cache.images.iter().position(|image| { + (image.producer, image.width, image.height) == (frame.producer, width, height) + }) { + Some(index) => index, + None => { + let stale = cache + .images + .iter() + .any(|image| image.producer == frame.producer) + || cache.images.len() >= MAX_IMAGES; + if stale { + // Their semaphores may be in flight. + wait_idle(device); + cache + .images + .retain(|image| image.producer != frame.producer); + if cache.images.len() >= MAX_IMAGES { + cache.images.remove(0); + } + } + let image = + unsafe { create_shared(device, frame.producer, width, height, with_semaphores) }?; + cache.images.push(image); + cache.images.len() - 1 + } + }; + let image = &mut cache.images[index]; + if image.semaphores.is_none() && image.read { + // No release semaphore: our last read must be over before GL writes. + wait_idle(device); + } + let (memory_fd, ready_fd, release_fd) = image.fds.take().unwrap_or((-1, -1, -1)); + let target = CanvasLinuxFrameTarget { + size: std::mem::size_of::() as u32, + memory_fd, + id: image.id, + allocation_size: image.allocation_size, + width, + height, + ready_fd, + release_fd, + wait_release: image.release_pending as u32, + reserved: 0, + }; + if !unsafe { copy_to(frame, &target) } { + // Its fds may be gone: start over with a new image next time. + cache.images.remove(index); + return None; + } + let image = &mut cache.images[index]; + image.read = true; + if let Some((ready, release)) = image.semaphores { + let hal_queue = unsafe { Arc::clone(&queue.queue.id).as_hal::() }?; + hal_queue.add_wait_semaphore(ready, None, vk::PipelineStageFlags::ALL_COMMANDS); + hal_queue.add_signal_semaphore(release, None); + image.release_pending = true; + } + Some(Arc::clone(&image.texture)) +} + +unsafe fn upload( + queue: &CanvasGPUQueue, + frame: &crate::linux_video_frame::CanvasLinuxVideoFrame, +) -> Option> { + let (width, height) = (frame.width, frame.height); + if frame.data.is_null() || frame.stride < width * 4 { + return None; + } + let texture = { + let mut cache = queue.linux_frames.lock(); + let fits = |texture: &Arc| { + let size = texture.descriptor().size; + (size.width, size.height) == (width, height) + }; + match cache.uploads.iter().find(|texture| fits(texture)) { + Some(texture) => Arc::clone(texture), + None => { + let texture = + queue + .device_id + .create_texture(&wgpu_core::resource::TextureDescriptor { + label: Some(Cow::Borrowed("videoBlit:Upload")), + size: wgt::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgt::TextureDimension::D2, + format: wgt::TextureFormat::Rgba8Unorm, + usage: wgt::TextureUsages::TEXTURE_BINDING + | wgt::TextureUsages::COPY_DST, + view_formats: vec![], + }); + cache.uploads.retain(|texture| fits(texture)); + cache.uploads.push(Arc::clone(&texture)); + texture + } + } + }; + let length = frame.stride as usize * (height as usize - 1) + width as usize * 4; + let data = unsafe { std::slice::from_raw_parts(frame.data, length) }; + queue.queue.id.write_texture( + wgt::TexelCopyTextureInfo { + texture: Arc::clone(&texture), + mip_level: 0, + origin: wgt::Origin3d::ZERO, + aspect: wgt::TextureAspect::All, + }, + data, + &wgt::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(frame.stride), + rows_per_image: Some(height), + }, + &wgt::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + Some(texture) +} + +/// The frame as a texture to sample in the queue's next submit, which must follow at once. +pub(crate) unsafe fn import_and_stage( + queue: &CanvasGPUQueue, + handle: *mut c_void, + width: u32, + height: u32, +) -> Option> { + let frame = unsafe { crate::linux_video_frame::frame(handle) }?; + if (frame.width, frame.height) != (width, height) { + return None; + } + let kind = frame.kind; + let texture = match kind { + KIND_GL => unsafe { shared(queue, frame) }, + KIND_MEMORY => unsafe { upload(queue, frame) }, + _ => None, + }; + crate::linux_video_frame::log_path("WebGPU", kind, texture.is_some()); + texture +} + +/// Drawn into a plane of our own at once: the external texture may be sampled after the producer +/// wrote the next frame. +pub(crate) unsafe fn import_external_plane( + device: &CanvasGPUDevice, + handle: *mut c_void, + width: u32, + height: u32, +) -> Option> { + let queue = &*device.queue; + let size = wgt::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + let plane = device + .device + .create_texture(&wgpu_core::resource::TextureDescriptor { + label: Some(Cow::Borrowed("externalTexture:Plane")), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgt::TextureDimension::D2, + format: wgt::TextureFormat::Rgba8Unorm, + usage: wgt::TextureUsages::TEXTURE_BINDING | wgt::TextureUsages::RENDER_ATTACHMENT, + view_formats: vec![], + }); + let source = unsafe { import_and_stage(queue, handle, width, height) }?; + unsafe { + super::gpu_native_texture::blit_texture( + queue, + &source, + width, + height, + 0, + 0, + false, + &plane, + 0, + 0, + (0, 0), + size, + ) + } + .then_some(plane) +} + +/// 53 bits of the device UUID (as canvas-media folds GL's), for `supportsGPUFrames`; 0 if +/// unknown. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_webgpu_device_get_uuid_key( + device: *const CanvasGPUDevice, +) -> u64 { + let Some(device) = (unsafe { device.as_ref() }) else { + return 0; + }; + let Some((uuid, _)) = device.queue.linux_frames.lock().device(&device.device) else { + return 0; + }; + let mut key = 0u64; + for (i, byte) in uuid.iter().enumerate() { + key ^= (*byte as u64) << ((i % 7) * 8); + } + key & ((1 << 53) - 1) +} diff --git a/crates/canvas-c/src/webgpu/mod.rs b/crates/canvas-c/src/webgpu/mod.rs index 4214f7713..6a5edc67b 100644 --- a/crates/canvas-c/src/webgpu/mod.rs +++ b/crates/canvas-c/src/webgpu/mod.rs @@ -70,5 +70,9 @@ pub mod gpu_shared_frame; pub mod gpu_supported_limits; pub mod gpu_texture; pub mod gpu_texture_view; +#[cfg(target_os = "linux")] +pub mod linux_frames; +#[cfg(target_os = "linux")] +pub mod linux_video_frame; pub mod prelude; pub mod structs; diff --git a/crates/canvas-c/src/webgpu/prelude.rs b/crates/canvas-c/src/webgpu/prelude.rs index e558dc65a..166b7cb3b 100644 --- a/crates/canvas-c/src/webgpu/prelude.rs +++ b/crates/canvas-c/src/webgpu/prelude.rs @@ -71,9 +71,8 @@ pub fn build_features(features: wgt::Features) -> Vec<&'static str> { return_features.push("float32-blendable"); } - if features.contains(wgt::Features::SUBGROUP) { - return_features.push("subgroups"); - } + // Not "subgroups": shaders that use it start with `enable subgroups;`, which naga does not + // parse yet (gfx-rs/wgpu#5555), so advertising it breaks them (three.js's compute shaders). if features.contains(wgt::Features::TEXTURE_COMPRESSION_ASTC_SLICED_3D) { return_features.push("texture-compression-astc-sliced-3d"); diff --git a/crates/canvas-core/Cargo.toml b/crates/canvas-core/Cargo.toml index 24d3af6d7..3b892390a 100644 --- a/crates/canvas-core/Cargo.toml +++ b/crates/canvas-core/Cargo.toml @@ -55,6 +55,11 @@ windows = { workspace = true, optional = true, features = [ "Win32_UI_WindowsAndMessaging", ] } +# Linux: GLES on the system EGL (Mesa), sharing frames with GTK through EGLImages. +[target.'cfg(target_os = "linux")'.dependencies] +khronos-egl = { version = "6.0", features = ["dynamic"], optional = true } +libloading = { version = "0.8", optional = true } + [[example]] name = "d3d_offscreen" required-features = ["d3d"] diff --git a/crates/canvas-core/src/gpu/gl/egl.rs b/crates/canvas-core/src/gpu/gl/egl.rs index 4d6495ddf..767fe40a9 100644 --- a/crates/canvas-core/src/gpu/gl/egl.rs +++ b/crates/canvas-core/src/gpu/gl/egl.rs @@ -7,6 +7,9 @@ //! Surfaces are pbuffers. Presenting to the screen goes through a D3D11 texture shared with //! ANGLE (see `create_texture_context`) that is copied into a composition swapchain; window //! surfaces are not used because the app's native view is a WinUI `SwapChainPanel`, not an HWND. +//! +//! On Linux it is the system's (Mesa) EGL, on the host's display (GDK's, see `set_host_display`) +//! so the host can hand frames to GTK as EGLImages, else on a surfaceless display. use std::cell::Cell; use std::ffi::c_void; @@ -274,7 +277,72 @@ fn platform_display(instance: &EglInstance) -> Option { } } -#[cfg(not(target_os = "windows"))] +/// Linux: a frame-end callback, `(width, height)` of the drawing buffer. +#[cfg(target_os = "linux")] +pub type PresentHook = Arc; + +#[cfg(target_os = "linux")] +static HOST_DISPLAY: AtomicUsize = AtomicUsize::new(0); + +#[cfg(target_os = "linux")] +static BEFORE_BIND: OnceLock = OnceLock::new(); + +/// Linux: the EGL display to render on (the toolkit's), set before the first context is made. +/// `false` once EGL is already up on another display. +#[cfg(target_os = "linux")] +pub fn set_host_display(display: *mut c_void) -> bool { + HOST_DISPLAY.store(display as usize, Ordering::Relaxed); + match EGL.get() { + None => true, + Some(egl) => egl + .as_ref() + .is_some_and(|egl| egl.display.as_ptr() == display), + } +} + +/// Linux: runs before a canvas context is bound, so a toolkit that caches its own current +/// context (GDK) can forget it. +#[cfg(target_os = "linux")] +pub fn set_before_bind(hook: fn()) { + let _ = BEFORE_BIND.set(hook); +} + +/// The display canvases render on, null before the first context. +pub fn egl_display() -> *mut c_void { + EGL.get() + .and_then(Option::as_ref) + .map_or(std::ptr::null_mut(), |egl| egl.display.as_ptr()) +} + +#[cfg(target_os = "linux")] +fn platform_display(instance: &EglInstance) -> Option { + const EGL_PLATFORM_SURFACELESS_MESA: egl::Enum = 0x31DD; + let host = HOST_DISPLAY.load(Ordering::Relaxed); + if host != 0 { + return Some(unsafe { egl::Display::from_ptr(host as *mut c_void) }); + } + let client = instance + .query_string(None, egl::EXTENSIONS) + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if client + .split(' ') + .any(|e| e == "EGL_MESA_platform_surfaceless") + { + if let Ok(display) = unsafe { + instance.get_platform_display( + EGL_PLATFORM_SURFACELESS_MESA, + egl::DEFAULT_DISPLAY, + &[egl::ATTRIB_NONE], + ) + } { + return Some(display); + } + } + unsafe { instance.get_display(egl::DEFAULT_DISPLAY) } +} + +#[cfg(not(any(target_os = "windows", target_os = "linux")))] fn platform_display(instance: &EglInstance) -> Option { unsafe { instance.get_display(egl::DEFAULT_DISPLAY) } } @@ -356,10 +424,23 @@ fn invalidate_surface_bindings() { fn bind(context: egl::Context, surface: egl::Surface) -> bool { let target = binding(context, surface); + // Linux: GTK binds its own contexts on this thread, so only EGL knows what is current. + #[cfg(not(target_os = "linux"))] if CURRENT_EGL_BINDING.with(|c| c.get()) == target { return true; } let Some(egl) = shared() else { return false }; + #[cfg(target_os = "linux")] + { + if egl.instance.get_current_context() == Some(context) + && egl.instance.get_current_surface(egl::DRAW) == Some(surface) + { + return true; + } + if let Some(hook) = BEFORE_BIND.get() { + hook(); + } + } let bound = egl .instance .make_current(egl.display, Some(surface), Some(surface), Some(context)) @@ -419,6 +500,9 @@ pub(crate) struct GLContextInner { #[cfg(target_os = "windows")] xaml: Option, dimensions: Arc>, + /// Linux: shows each finished frame (`swap_buffers`), with the context current. + #[cfg(target_os = "linux")] + present_hook: parking_lot::Mutex>, /// Set once the context reports a reset: a lost context stays lost. #[cfg(target_os = "windows")] lost: std::sync::atomic::AtomicBool, @@ -670,6 +754,8 @@ impl GLContext { })), #[cfg(target_os = "windows")] lost: Default::default(), + #[cfg(target_os = "linux")] + present_hook: Default::default(), })) } @@ -978,6 +1064,19 @@ impl GLContext { } } + /// Linux surfaces are always pbuffers. + #[cfg(target_os = "linux")] + pub fn is_pbuffer(&self) -> bool { + true + } + + /// The EGL context handle (Linux hosts share its textures with the toolkit). + pub fn egl_context(&self) -> *mut c_void { + self.0 + .context + .map_or(std::ptr::null_mut(), |context| context.as_ptr()) + } + pub fn set_vsync(&self, sync: bool) -> bool { // Pbuffers are never presented through EGL; pacing comes from the swapchain. let Some(egl) = shared() else { return false }; @@ -1002,9 +1101,20 @@ impl GLContext { return false; } unsafe { gl_bindings::Flush() }; + #[cfg(target_os = "linux")] + if let Some(hook) = self.0.present_hook.lock().clone() { + let (width, height) = self.get_surface_dimensions(); + hook(width, height); + } true } + /// Linux: `hook(width, height)` runs after each frame, on the context's thread. + #[cfg(target_os = "linux")] + pub fn set_present_hook(&self, hook: Option) { + *self.0.present_hook.lock() = hook; + } + pub fn get_surface_width(&self) -> i32 { self.0.dimensions.read().width } diff --git a/crates/canvas-core/src/gpu/gl/mod.rs b/crates/canvas-core/src/gpu/gl/mod.rs index 00d3e024f..1d363f064 100644 --- a/crates/canvas-core/src/gpu/gl/mod.rs +++ b/crates/canvas-core/src/gpu/gl/mod.rs @@ -15,13 +15,13 @@ mod android; #[cfg(target_os = "android")] pub use android::*; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] mod egl; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] mod program_cache; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] pub use egl::*; pub fn get_shader_info_log(shader: u32) -> String { diff --git a/crates/canvas-gamepad-napi/Cargo.toml b/crates/canvas-gamepad-napi/Cargo.toml index a30d44611..66fa800cb 100644 --- a/crates/canvas-gamepad-napi/Cargo.toml +++ b/crates/canvas-gamepad-napi/Cargo.toml @@ -3,7 +3,7 @@ name = "canvas-gamepad-napi" version = "0.1.0" edition = "2021" publish = false -description = "Node-API binding for @nativescript/canvas-gamepad on Windows: Windows.Gaming.Input readings for navigator.getGamepads()." +description = "Node-API binding for @nativescript/canvas-gamepad: Windows.Gaming.Input on Windows, gilrs (evdev) on Linux, for navigator.getGamepads()." [lib] crate-type = ["cdylib"] @@ -25,5 +25,10 @@ windows = { workspace = true, features = [ "Gaming_Input", ] } +# evdev pads through gilrs (SDL mappings to the standard layout); hot-plug checked on the main loop. +[target.'cfg(target_os = "linux")'.dependencies] +gilrs = "0.11" +glib = "0.22" + [build-dependencies] napi-build = "2.5.0" diff --git a/crates/canvas-gamepad-napi/package.json b/crates/canvas-gamepad-napi/package.json index ddc9972a3..39ac03306 100644 --- a/crates/canvas-gamepad-napi/package.json +++ b/crates/canvas-gamepad-napi/package.json @@ -2,7 +2,7 @@ "name": "@nativescript/canvas-gamepad-napi", "private": true, "version": "0.1.0", - "description": "Node-API build of the canvas-gamepad native module (Windows.Gaming.Input poller for Windows).", + "description": "Node-API build of the canvas-gamepad native module (Windows.Gaming.Input on Windows, gilrs on Linux).", "napi": { "binaryName": "canvasgamepad", "targets": [ diff --git a/crates/canvas-gamepad-napi/src/layout.rs b/crates/canvas-gamepad-napi/src/layout.rs new file mode 100644 index 000000000..153cb0fe2 --- /dev/null +++ b/crates/canvas-gamepad-napi/src/layout.rs @@ -0,0 +1,27 @@ +//! The shared buffer's layout. Keep in sync with packages/canvas-gamepad/common.ts. + +pub const MAX_GAMEPADS: usize = 4; +pub const MAX_AXES: usize = 8; +pub const SLOT_CONNECTED: usize = 0; +pub const SLOT_SEQUENCE: usize = 1; +pub const SLOT_MAPPING: usize = 2; +pub const SLOT_AXIS_COUNT: usize = 3; +pub const SLOT_BUTTON_COUNT: usize = 4; +pub const SLOT_AXES: usize = 5; +pub const SLOT_BUTTONS: usize = SLOT_AXES + MAX_AXES; +pub const SLOT_STRIDE: usize = 80; +pub const BUTTON_PRESSED: u32 = 1; +pub const BUTTON_TOUCHED: u32 = 2; +pub const MAPPING_STANDARD: f32 = 1.0; +pub const STANDARD_AXES: f32 = 4.0; +// Float32 holds integers exactly up to 2^24. +pub const SEQUENCE_WRAP: f32 = 16_777_216.0; +pub const TRIGGER_PRESSED: f32 = 0.1; + +pub fn write_button(slot: &mut [f32], index: usize, value: f32, pressed: bool) { + let offset = SLOT_BUTTONS + index * 2; + let touched = pressed || value > 0.0; + slot[offset] = value; + slot[offset + 1] = ((if pressed { BUTTON_PRESSED } else { 0 }) + | (if touched { BUTTON_TOUCHED } else { 0 })) as f32; +} diff --git a/crates/canvas-gamepad-napi/src/lib.rs b/crates/canvas-gamepad-napi/src/lib.rs index c53df25f5..e7e3aeb10 100644 --- a/crates/canvas-gamepad-napi/src/lib.rs +++ b/crates/canvas-gamepad-napi/src/lib.rs @@ -1,211 +1,9 @@ -#![cfg(target_os = "windows")] #![deny(clippy::all)] -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +mod layout; -use napi::bindgen_prelude::{Float32Array, Function, ObjectFinalize}; -use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; -use napi::{Env, Result}; -use napi_derive::napi; -use windows::Foundation::EventHandler; -use windows::Gaming::Input::{Gamepad, GamepadButtons, RawGameController}; +#[cfg(target_os = "windows")] +mod windows; -// Keep in sync with packages/canvas-gamepad/common.ts. -const MAX_GAMEPADS: usize = 4; -const MAX_AXES: usize = 8; -const SLOT_CONNECTED: usize = 0; -const SLOT_SEQUENCE: usize = 1; -const SLOT_MAPPING: usize = 2; -const SLOT_AXIS_COUNT: usize = 3; -const SLOT_BUTTON_COUNT: usize = 4; -const SLOT_AXES: usize = 5; -const SLOT_BUTTONS: usize = SLOT_AXES + MAX_AXES; -const SLOT_STRIDE: usize = 80; -const BUTTON_PRESSED: u32 = 1; -const BUTTON_TOUCHED: u32 = 2; -const MAPPING_STANDARD: f32 = 1.0; -const STANDARD_AXES: f32 = 4.0; -// Windows.Gaming.Input.Gamepad has no guide button. -const STANDARD_BUTTONS: f32 = 16.0; -// Float32 holds integers exactly up to 2^24. -const SEQUENCE_WRAP: f32 = 16_777_216.0; -const TRIGGER_PRESSED: f32 = 0.1; -const MICROSOFT_VENDOR: u16 = 0x045e; - -const DIGITAL: [(usize, GamepadButtons); 14] = [ - (0, GamepadButtons::A), - (1, GamepadButtons::B), - (2, GamepadButtons::X), - (3, GamepadButtons::Y), - (4, GamepadButtons::LeftShoulder), - (5, GamepadButtons::RightShoulder), - (8, GamepadButtons::View), - (9, GamepadButtons::Menu), - (10, GamepadButtons::LeftThumbstick), - (11, GamepadButtons::RightThumbstick), - (12, GamepadButtons::DPadUp), - (13, GamepadButtons::DPadDown), - (14, GamepadButtons::DPadLeft), - (15, GamepadButtons::DPadRight), -]; - -fn write_button(slot: &mut [f32], index: usize, value: f32, pressed: bool) { - let offset = SLOT_BUTTONS + index * 2; - let touched = pressed || value > 0.0; - slot[offset] = value; - slot[offset + 1] = ((if pressed { BUTTON_PRESSED } else { 0 }) | (if touched { BUTTON_TOUCHED } else { 0 })) as f32; -} - -type ChangeCallback<'a> = Function<'a, (), UnknownReturnValue>; - -#[napi(js_name = "NSCGamepadPoller", custom_finalize)] -pub struct NSCGamepadPoller { - slots: [Option; MAX_GAMEPADS], - ids: [String; MAX_GAMEPADS], - dirty: Arc, - tokens: Option<(i64, i64)>, -} - -impl ObjectFinalize for NSCGamepadPoller { - fn finalize(mut self, _: Env) -> Result<()> { - self.close(); - Ok(()) - } -} - -#[napi] -impl NSCGamepadPoller { - #[napi(constructor, ts_args_type = "onConnectionChanged: () => void")] - pub fn new(on_change: ChangeCallback) -> Result { - let dirty = Arc::new(AtomicBool::new(true)); - let tsfn = on_change.build_threadsafe_function::<()>().weak::().build()?; - let tsfn = Arc::new(tsfn); - - let handler = { - let dirty = Arc::clone(&dirty); - let tsfn = Arc::clone(&tsfn); - EventHandler::::new(move |_, _| { - dirty.store(true, Ordering::Release); - tsfn.call((), ThreadsafeFunctionCallMode::NonBlocking); - Ok(()) - }) - }; - let tokens = match (Gamepad::GamepadAdded(&handler), Gamepad::GamepadRemoved(&handler)) { - (Ok(added), Ok(removed)) => Some((added, removed)), - _ => None, - }; - - Ok(Self { - slots: Default::default(), - ids: Default::default(), - dirty, - tokens, - }) - } - - #[napi] - pub fn poll(&mut self, mut buffer: Float32Array) -> u32 { - // SAFETY: the JS caller owns the array and is blocked for the duration of the call. - let buffer: &mut [f32] = unsafe { buffer.as_mut() }; - if buffer.len() < MAX_GAMEPADS * SLOT_STRIDE { - return 0; - } - let changed = if self.dirty.swap(false, Ordering::AcqRel) { - self.sync(buffer) - } else { - 0 - }; - - for (index, pad) in self.slots.iter().enumerate() { - let Some(pad) = pad else { continue }; - let Ok(reading) = pad.GetCurrentReading() else { continue }; - let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE]; - - let axes = &mut slot[SLOT_AXES..SLOT_AXES + 4]; - axes[0] = reading.LeftThumbstickX as f32; - axes[1] = -reading.LeftThumbstickY as f32; - axes[2] = reading.RightThumbstickX as f32; - axes[3] = -reading.RightThumbstickY as f32; - - for (button, flag) in DIGITAL { - let pressed = reading.Buttons.contains(flag); - write_button(slot, button, if pressed { 1.0 } else { 0.0 }, pressed); - } - let left = reading.LeftTrigger as f32; - let right = reading.RightTrigger as f32; - write_button(slot, 6, left, left > TRIGGER_PRESSED); - write_button(slot, 7, right, right > TRIGGER_PRESSED); - - slot[SLOT_SEQUENCE] = (slot[SLOT_SEQUENCE] + 1.0) % SEQUENCE_WRAP; - } - changed - } - - #[napi] - pub fn id(&self, index: u32) -> String { - self.ids.get(index as usize).cloned().unwrap_or_default() - } - - #[napi] - pub fn close(&mut self) { - if let Some((added, removed)) = self.tokens.take() { - let _ = Gamepad::RemoveGamepadAdded(added); - let _ = Gamepad::RemoveGamepadRemoved(removed); - } - self.slots = Default::default(); - } -} - -impl NSCGamepadPoller { - fn sync(&mut self, buffer: &mut [f32]) -> u32 { - let current: Vec = Gamepad::Gamepads().map(|list| list.into_iter().collect()).unwrap_or_default(); - let mut changed = 0u32; - - for index in 0..MAX_GAMEPADS { - let gone = matches!(&self.slots[index], Some(pad) if !current.contains(pad)); - if gone { - self.slots[index] = None; - self.ids[index].clear(); - buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE].fill(0.0); - changed |= 1 << index; - } - } - - for pad in current { - if self.slots.iter().any(|slot| slot.as_ref() == Some(&pad)) { - continue; - } - let Some(index) = self.slots.iter().position(Option::is_none) else { break }; - let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE]; - slot.fill(0.0); - slot[SLOT_CONNECTED] = 1.0; - slot[SLOT_MAPPING] = MAPPING_STANDARD; - slot[SLOT_AXIS_COUNT] = STANDARD_AXES; - slot[SLOT_BUTTON_COUNT] = STANDARD_BUTTONS; - self.ids[index] = describe(&pad); - self.slots[index] = Some(pad); - changed |= 1 << index; - } - changed - } -} - -fn describe(pad: &Gamepad) -> String { - match RawGameController::FromGameController(pad) { - Ok(raw) => { - let name = raw.DisplayName().map(|n| n.to_string()).unwrap_or_default(); - let vendor = raw.HardwareVendorId().unwrap_or(0); - let product = raw.HardwareProductId().unwrap_or(0); - // Over Bluetooth Windows names Xbox pads by their HID class; Chrome reports the product name. - let generic = name.is_empty() || name.starts_with("HID-compliant"); - let name = match (generic, vendor) { - (true, MICROSOFT_VENDOR) => "Xbox Wireless Controller".to_string(), - (true, _) if name.is_empty() => "Xbox Controller".to_string(), - _ => name, - }; - format!("{name} (STANDARD GAMEPAD Vendor: {vendor:04x} Product: {product:04x})") - } - Err(_) => "Xbox Controller (STANDARD GAMEPAD)".to_string(), - } -} +#[cfg(target_os = "linux")] +mod linux; diff --git a/crates/canvas-gamepad-napi/src/linux.rs b/crates/canvas-gamepad-napi/src/linux.rs new file mode 100644 index 000000000..1f01ebfaf --- /dev/null +++ b/crates/canvas-gamepad-napi/src/linux.rs @@ -0,0 +1,211 @@ +//! Linux: pads read through gilrs (evdev, hot-plug via udev) and mapped to the standard layout +//! with SDL's controller database. Connections are watched on the GLib main loop between polls. + +use std::cell::RefCell; +use std::rc::Rc; + +use gilrs::{Axis, Button, EventType, GamepadId, Gilrs}; +use napi::bindgen_prelude::{Float32Array, Function, ObjectFinalize}; +use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; +use napi::{Env, Error, Result}; +use napi_derive::napi; + +use crate::layout::*; + +// With the guide (home) button. +const STANDARD_BUTTONS: f32 = 17.0; +const WATCH_INTERVAL_MS: u64 = 250; + +const DIGITAL: [(usize, Button); 15] = [ + (0, Button::South), + (1, Button::East), + (2, Button::West), + (3, Button::North), + (4, Button::LeftTrigger), + (5, Button::RightTrigger), + (8, Button::Select), + (9, Button::Start), + (10, Button::LeftThumb), + (11, Button::RightThumb), + (12, Button::DPadUp), + (13, Button::DPadDown), + (14, Button::DPadLeft), + (15, Button::DPadRight), + (16, Button::Mode), +]; + +struct State { + gilrs: Gilrs, + slots: [Option; MAX_GAMEPADS], + ids: [String; MAX_GAMEPADS], + /// A pad came or went since the last poll. + dirty: bool, +} + +impl State { + /// Reads pending events (gilrs updates pad state as they are read); `true` on a connection change. + fn pump(&mut self) -> bool { + let mut changed = false; + while let Some(event) = self.gilrs.next_event() { + if matches!(event.event, EventType::Connected | EventType::Disconnected) { + changed = true; + } + } + self.dirty |= changed; + changed + } + + fn sync(&mut self, buffer: &mut [f32]) -> u32 { + let mut changed = 0u32; + for index in 0..MAX_GAMEPADS { + let gone = self.slots[index].is_some_and(|id| !self.gilrs.connected_gamepad(id).is_some()); + if gone { + self.slots[index] = None; + self.ids[index].clear(); + buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE].fill(0.0); + changed |= 1 << index; + } + } + let connected: Vec = self.gilrs.gamepads().map(|(id, _)| id).collect(); + for id in connected { + if self.slots.contains(&Some(id)) { + continue; + } + let Some(index) = self.slots.iter().position(Option::is_none) else { + break; + }; + let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE]; + slot.fill(0.0); + slot[SLOT_CONNECTED] = 1.0; + slot[SLOT_MAPPING] = MAPPING_STANDARD; + slot[SLOT_AXIS_COUNT] = STANDARD_AXES; + slot[SLOT_BUTTON_COUNT] = STANDARD_BUTTONS; + let pad = self.gilrs.gamepad(id); + let vendor = pad.vendor_id().unwrap_or(0); + let product = pad.product_id().unwrap_or(0); + // As Chrome reports a standard-mapped pad. + self.ids[index] = format!( + "{} (STANDARD GAMEPAD Vendor: {vendor:04x} Product: {product:04x})", + pad.name() + ); + self.slots[index] = Some(id); + changed |= 1 << index; + } + changed + } +} + +type ChangeCallback<'a> = Function<'a, (), UnknownReturnValue>; + +#[napi(js_name = "NSCGamepadPoller", custom_finalize)] +pub struct NSCGamepadPoller { + state: Rc>, + watch: Option, +} + +impl ObjectFinalize for NSCGamepadPoller { + fn finalize(mut self, _: Env) -> Result<()> { + self.close(); + Ok(()) + } +} + +#[napi] +impl NSCGamepadPoller { + /// `onConnectionChanged()` runs when a pad comes or goes; `poll` then reports which. + #[napi(constructor, ts_args_type = "onConnectionChanged: () => void")] + pub fn new(on_change: ChangeCallback) -> Result { + let gilrs = match Gilrs::new() { + Ok(gilrs) => gilrs, + // No udev: an instance that never reports a pad. + Err(gilrs::Error::NotImplemented(dummy)) => dummy, + Err(e) => return Err(Error::from_reason(format!("gamepads: {e}"))), + }; + let state = Rc::new(RefCell::new(State { + gilrs, + slots: Default::default(), + ids: Default::default(), + dirty: true, + })); + let tsfn = on_change + .build_threadsafe_function::<()>() + .weak::() + .build()?; + let watched = Rc::clone(&state); + let watch = glib::timeout_add_local( + std::time::Duration::from_millis(WATCH_INTERVAL_MS), + move || { + if watched.borrow_mut().pump() { + tsfn.call((), ThreadsafeFunctionCallMode::NonBlocking); + } + glib::ControlFlow::Continue + }, + ); + Ok(Self { + state, + watch: Some(watch), + }) + } + + /// Writes every pad's reading into `buffer`; returns a bit per slot whose connection changed. + #[napi] + pub fn poll(&mut self, mut buffer: Float32Array) -> u32 { + // SAFETY: the JS caller owns the array and is blocked for the duration of the call. + let buffer: &mut [f32] = unsafe { buffer.as_mut() }; + if buffer.len() < MAX_GAMEPADS * SLOT_STRIDE { + return 0; + } + let mut state = self.state.borrow_mut(); + state.pump(); + let changed = if std::mem::take(&mut state.dirty) { + state.sync(buffer) + } else { + 0 + }; + + for index in 0..MAX_GAMEPADS { + let Some(id) = state.slots[index] else { + continue; + }; + let Some(pad) = state.gilrs.connected_gamepad(id) else { + continue; + }; + let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE]; + let axes = &mut slot[SLOT_AXES..SLOT_AXES + 4]; + // gilrs's Y axes point up; the web's down. + axes[0] = pad.value(Axis::LeftStickX); + axes[1] = -pad.value(Axis::LeftStickY); + axes[2] = pad.value(Axis::RightStickX); + axes[3] = -pad.value(Axis::RightStickY); + for (button, which) in DIGITAL { + let pressed = pad.is_pressed(which); + write_button(slot, button, if pressed { 1.0 } else { 0.0 }, pressed); + } + for (button, which) in [(6, Button::LeftTrigger2), (7, Button::RightTrigger2)] { + let value = pad.button_data(which).map_or(0.0, |data| data.value()); + write_button(slot, button, value, value > TRIGGER_PRESSED); + } + slot[SLOT_SEQUENCE] = (slot[SLOT_SEQUENCE] + 1.0) % SEQUENCE_WRAP; + } + changed + } + + #[napi] + pub fn id(&self, index: u32) -> String { + self + .state + .borrow() + .ids + .get(index as usize) + .cloned() + .unwrap_or_default() + } + + #[napi] + pub fn close(&mut self) { + if let Some(watch) = self.watch.take() { + watch.remove(); + } + self.state.borrow_mut().slots = Default::default(); + } +} diff --git a/crates/canvas-gamepad-napi/src/windows.rs b/crates/canvas-gamepad-napi/src/windows.rs new file mode 100644 index 000000000..b28d7bfc3 --- /dev/null +++ b/crates/canvas-gamepad-napi/src/windows.rs @@ -0,0 +1,185 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use napi::bindgen_prelude::{Float32Array, Function, ObjectFinalize}; +use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; +use napi::{Env, Result}; +use napi_derive::napi; +use windows::Foundation::EventHandler; +use windows::Gaming::Input::{Gamepad, GamepadButtons, RawGameController}; + +use crate::layout::*; + +// Windows.Gaming.Input.Gamepad has no guide button. +const STANDARD_BUTTONS: f32 = 16.0; +const MICROSOFT_VENDOR: u16 = 0x045e; + +const DIGITAL: [(usize, GamepadButtons); 14] = [ + (0, GamepadButtons::A), + (1, GamepadButtons::B), + (2, GamepadButtons::X), + (3, GamepadButtons::Y), + (4, GamepadButtons::LeftShoulder), + (5, GamepadButtons::RightShoulder), + (8, GamepadButtons::View), + (9, GamepadButtons::Menu), + (10, GamepadButtons::LeftThumbstick), + (11, GamepadButtons::RightThumbstick), + (12, GamepadButtons::DPadUp), + (13, GamepadButtons::DPadDown), + (14, GamepadButtons::DPadLeft), + (15, GamepadButtons::DPadRight), +]; + +type ChangeCallback<'a> = Function<'a, (), UnknownReturnValue>; + +#[napi(js_name = "NSCGamepadPoller", custom_finalize)] +pub struct NSCGamepadPoller { + slots: [Option; MAX_GAMEPADS], + ids: [String; MAX_GAMEPADS], + dirty: Arc, + tokens: Option<(i64, i64)>, +} + +impl ObjectFinalize for NSCGamepadPoller { + fn finalize(mut self, _: Env) -> Result<()> { + self.close(); + Ok(()) + } +} + +#[napi] +impl NSCGamepadPoller { + #[napi(constructor, ts_args_type = "onConnectionChanged: () => void")] + pub fn new(on_change: ChangeCallback) -> Result { + let dirty = Arc::new(AtomicBool::new(true)); + let tsfn = on_change.build_threadsafe_function::<()>().weak::().build()?; + let tsfn = Arc::new(tsfn); + + let handler = { + let dirty = Arc::clone(&dirty); + let tsfn = Arc::clone(&tsfn); + EventHandler::::new(move |_, _| { + dirty.store(true, Ordering::Release); + tsfn.call((), ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) + }) + }; + let tokens = match (Gamepad::GamepadAdded(&handler), Gamepad::GamepadRemoved(&handler)) { + (Ok(added), Ok(removed)) => Some((added, removed)), + _ => None, + }; + + Ok(Self { + slots: Default::default(), + ids: Default::default(), + dirty, + tokens, + }) + } + + #[napi] + pub fn poll(&mut self, mut buffer: Float32Array) -> u32 { + // SAFETY: the JS caller owns the array and is blocked for the duration of the call. + let buffer: &mut [f32] = unsafe { buffer.as_mut() }; + if buffer.len() < MAX_GAMEPADS * SLOT_STRIDE { + return 0; + } + let changed = if self.dirty.swap(false, Ordering::AcqRel) { + self.sync(buffer) + } else { + 0 + }; + + for (index, pad) in self.slots.iter().enumerate() { + let Some(pad) = pad else { continue }; + let Ok(reading) = pad.GetCurrentReading() else { continue }; + let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE]; + + let axes = &mut slot[SLOT_AXES..SLOT_AXES + 4]; + axes[0] = reading.LeftThumbstickX as f32; + axes[1] = -reading.LeftThumbstickY as f32; + axes[2] = reading.RightThumbstickX as f32; + axes[3] = -reading.RightThumbstickY as f32; + + for (button, flag) in DIGITAL { + let pressed = reading.Buttons.contains(flag); + write_button(slot, button, if pressed { 1.0 } else { 0.0 }, pressed); + } + let left = reading.LeftTrigger as f32; + let right = reading.RightTrigger as f32; + write_button(slot, 6, left, left > TRIGGER_PRESSED); + write_button(slot, 7, right, right > TRIGGER_PRESSED); + + slot[SLOT_SEQUENCE] = (slot[SLOT_SEQUENCE] + 1.0) % SEQUENCE_WRAP; + } + changed + } + + #[napi] + pub fn id(&self, index: u32) -> String { + self.ids.get(index as usize).cloned().unwrap_or_default() + } + + #[napi] + pub fn close(&mut self) { + if let Some((added, removed)) = self.tokens.take() { + let _ = Gamepad::RemoveGamepadAdded(added); + let _ = Gamepad::RemoveGamepadRemoved(removed); + } + self.slots = Default::default(); + } +} + +impl NSCGamepadPoller { + fn sync(&mut self, buffer: &mut [f32]) -> u32 { + let current: Vec = Gamepad::Gamepads().map(|list| list.into_iter().collect()).unwrap_or_default(); + let mut changed = 0u32; + + for index in 0..MAX_GAMEPADS { + let gone = matches!(&self.slots[index], Some(pad) if !current.contains(pad)); + if gone { + self.slots[index] = None; + self.ids[index].clear(); + buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE].fill(0.0); + changed |= 1 << index; + } + } + + for pad in current { + if self.slots.iter().any(|slot| slot.as_ref() == Some(&pad)) { + continue; + } + let Some(index) = self.slots.iter().position(Option::is_none) else { break }; + let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE]; + slot.fill(0.0); + slot[SLOT_CONNECTED] = 1.0; + slot[SLOT_MAPPING] = MAPPING_STANDARD; + slot[SLOT_AXIS_COUNT] = STANDARD_AXES; + slot[SLOT_BUTTON_COUNT] = STANDARD_BUTTONS; + self.ids[index] = describe(&pad); + self.slots[index] = Some(pad); + changed |= 1 << index; + } + changed + } +} + +fn describe(pad: &Gamepad) -> String { + match RawGameController::FromGameController(pad) { + Ok(raw) => { + let name = raw.DisplayName().map(|n| n.to_string()).unwrap_or_default(); + let vendor = raw.HardwareVendorId().unwrap_or(0); + let product = raw.HardwareProductId().unwrap_or(0); + // Over Bluetooth Windows names Xbox pads by their HID class; Chrome reports the product name. + let generic = name.is_empty() || name.starts_with("HID-compliant"); + let name = match (generic, vendor) { + (true, MICROSOFT_VENDOR) => "Xbox Wireless Controller".to_string(), + (true, _) if name.is_empty() => "Xbox Controller".to_string(), + _ => name, + }; + format!("{name} (STANDARD GAMEPAD Vendor: {vendor:04x} Product: {product:04x})") + } + Err(_) => "Xbox Controller (STANDARD GAMEPAD)".to_string(), + } +} diff --git a/crates/canvas-media-napi/Cargo.toml b/crates/canvas-media-napi/Cargo.toml index c00fc0f0a..c32f0c2f7 100644 --- a/crates/canvas-media-napi/Cargo.toml +++ b/crates/canvas-media-napi/Cargo.toml @@ -3,7 +3,7 @@ name = "canvas-media-napi" version = "0.1.0" edition = "2021" publish = false -description = "Node-API binding for @nativescript/canvas-media on Windows: MediaPlayer events and frame-server frames for the Video and Audio views." +description = "Node-API binding for @nativescript/canvas-media: MediaPlayer events and frame-server frames on Windows, a GStreamer player shown by GTK 4 on Linux, for the Video and Audio views." [lib] crate-type = ["cdylib"] @@ -41,5 +41,15 @@ windows = { workspace = true, features = [ "Win32_System_WinRT_Direct3D11", ] } +# GStreamer playbin; frames shown by a GTK 4 (>= 4.6) picture. +[target.'cfg(target_os = "linux")'.dependencies] +gst = { package = "gstreamer", version = "0.25" } +gst-app = { package = "gstreamer-app", version = "0.25" } +gst-video = { package = "gstreamer-video", version = "0.25" } +gst-gl = { package = "gstreamer-gl", version = "0.25" } +gst-gl-egl = { package = "gstreamer-gl-egl", version = "0.25" } +libloading = "0.8" +gtk = { package = "gtk4", version = "0.11", features = ["v4_6"] } + [build-dependencies] napi-build = "2.5.0" diff --git a/crates/canvas-media-napi/src/lib.rs b/crates/canvas-media-napi/src/lib.rs index ec0ed78d5..cf5e30d08 100644 --- a/crates/canvas-media-napi/src/lib.rs +++ b/crates/canvas-media-napi/src/lib.rs @@ -1,795 +1,7 @@ -#![cfg(target_os = "windows")] #![deny(clippy::all)] -mod tap; +#[cfg(target_os = "windows")] +mod windows; -use std::cell::{Cell, RefCell}; -use std::mem::ManuallyDrop; -use std::rc::{Rc, Weak}; -use std::ffi::c_void; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; - -use napi::bindgen_prelude::{FnArgs, Function, ObjectFinalize, Uint8Array}; -use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; -use napi::{Env, Error, Result}; -use napi_derive::napi; -use windows::core::{IInspectable, IUnknown, Interface, RuntimeType, HRESULT}; -use windows::Foundation::TypedEventHandler; -use windows::Graphics::DirectX::Direct3D11::IDirect3DSurface; -use windows::Media::Playback::{MediaPlaybackSession, MediaPlayer, MediaPlayerFailedEventArgs}; -use windows::core::PCWSTR; -use windows::Win32::Foundation::{CloseHandle, GENERIC_ALL, HANDLE, HMODULE, POINT, RECT}; -use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP}; -use windows::Win32::Graphics::Direct3D11::{ - D3D11CreateDevice, ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence, - ID3D11Multithread, ID3D11Resource, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, - D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_FENCE_FLAG_SHARED, - D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_RESOURCE_MISC_SHARED, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, - D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING, -}; -use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC}; -use windows::Win32::Graphics::Dxgi::{ - IDXGIDevice, IDXGIResource1, IDXGISurface, DXGI_SHARED_RESOURCE_READ, DXGI_SHARED_RESOURCE_WRITE, -}; -use windows::Win32::System::WinRT::Direct3D11::CreateDirect3D11SurfaceFromDXGISurface; - -/// WinUI 3's `ISurfaceImageSourceNative` (microsoft.ui.xaml.media.dxinterop.h; not the UWP IID). -#[windows::core::interface("e4cecd6c-f14b-4f46-83c3-8bbda27c6504")] -unsafe trait ISurfaceImageSourceNative: windows::core::IUnknown { - fn SetDevice(&self, device: *mut c_void) -> HRESULT; - fn BeginDraw(&self, update_rect: RECT, surface: *mut *mut c_void, offset: *mut POINT) -> HRESULT; - fn EndDraw(&self) -> HRESULT; -} - -fn parse_pointer_key(key: &str) -> Option<*mut c_void> { - let key = key.trim(); - let address = match key.strip_prefix("0x").or_else(|| key.strip_prefix("0X")) { - Some(hex) => usize::from_str_radix(hex, 16).ok(), - None => key.parse().ok(), - }?; - (address != 0).then_some(address as *mut c_void) -} - -fn to_napi(error: windows::core::Error) -> Error { - Error::from_reason(error.message()) -} - -struct Device { - device: ID3D11Device, - context: ID3D11DeviceContext, - /// WebGPU shares frames only on the same adapter. - luid: u64, - /// Windows 10 1703+; without fences frames are not shared. - fences: Option<(ID3D11Device5, ID3D11DeviceContext4)>, -} - -// Multithread-protected (below), and D3D11 devices are free-threaded. -unsafe impl Send for Device {} -unsafe impl Sync for Device {} - -/// One device for every player. MediaPlayer copies frames into it on its own threads while the JS -/// thread reads them back or presents them, so its immediate context is multithread-protected. -fn device() -> Option<&'static Device> { - static DEVICE: OnceLock> = OnceLock::new(); - DEVICE - .get_or_init(|| create_device(D3D_DRIVER_TYPE_HARDWARE).or_else(|| create_device(D3D_DRIVER_TYPE_WARP))) - .as_ref() -} - -fn create_device(driver: D3D_DRIVER_TYPE) -> Option { - let (mut device, mut context) = (None, None); - unsafe { - D3D11CreateDevice( - None, - driver, - HMODULE::default(), - // BGRA for XAML SurfaceImageSources; video support for MediaPlayer's video processor. - D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT, - None, - D3D11_SDK_VERSION, - Some(&mut device), - None, - Some(&mut context), - ) - } - .ok()?; - let (device, context) = (device?, context?); - let multithread: ID3D11Multithread = context.cast().ok()?; - let _ = unsafe { multithread.SetMultithreadProtected(true) }; - let luid = (|| -> windows::core::Result { - let adapter = unsafe { device.cast::()?.GetAdapter()? }; - let luid = unsafe { adapter.GetDesc()? }.AdapterLuid; - Ok(luid.LowPart as u64 | ((luid.HighPart as u32 as u64) << 32)) - })() - .unwrap_or(0); - let fences = device.cast::().ok().zip(context.cast::().ok()); - Some(Device { device, context, luid, fences }) -} - -fn texture_desc(width: u32, height: u32, shared: bool) -> D3D11_TEXTURE2D_DESC { - D3D11_TEXTURE2D_DESC { - Width: width, - Height: height, - MipLevels: 1, - ArraySize: 1, - Format: DXGI_FORMAT_B8G8R8A8_UNORM, - SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, - CPUAccessFlags: 0, - // Opened by WebGPU on its D3D12 device (canvas-c's gpu_shared_frame). - MiscFlags: if shared { (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0 | D3D11_RESOURCE_MISC_SHARED.0) as u32 } else { 0 }, - } -} - -static NEXT_SHARED_ID: AtomicU64 = AtomicU64::new(1); - -struct SharedHandle { - handle: HANDLE, - /// Unique for the process's lifetime, unlike handle values: consumers cache by it. - id: u64, -} - -impl SharedHandle { - fn new(handle: HANDLE) -> Self { - Self { handle, id: NEXT_SHARED_ID.fetch_add(1, Ordering::Relaxed) } - } -} - -impl Drop for SharedHandle { - fn drop(&mut self) { - let _ = unsafe { CloseHandle(self.handle) }; - } -} - -struct Fences { - ready: ID3D11Fence, - ready_handle: SharedHandle, - release: ID3D11Fence, - release_handle: SharedHandle, -} - -impl Fences { - fn new(device: &ID3D11Device5) -> Option { - let create = || -> windows::core::Result<(ID3D11Fence, SharedHandle)> { - let mut fence: Option = None; - unsafe { device.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence)? }; - let fence = fence.ok_or_else(windows::core::Error::empty)?; - let handle = unsafe { fence.CreateSharedHandle(None, GENERIC_ALL.0, PCWSTR::null())? }; - Ok((fence, SharedHandle::new(handle))) - }; - let (ready, ready_handle) = create().ok()?; - let (release, release_handle) = create().ok()?; - Some(Fences { ready, ready_handle, release, release_handle }) - } -} - -/// `CanvasD3DSharedFrame` in canvas-c (crates/canvas-c/src/webgpu/gpu_shared_frame.rs): same layout. -#[repr(C)] -struct SharedFrameDesc { - size: u32, - consumed: u32, - adapter_luid: u64, - texture_id: u64, - texture: *mut c_void, - ready_fence_id: u64, - ready_fence: *mut c_void, - ready_value: u64, - release_fence_id: u64, - release_fence: *mut c_void, - release_value: u64, -} - -struct Frame { - texture: ID3D11Texture2D, - surface: IDirect3DSurface, - shared: Option, - /// The release value to wait for before writing the texture again. - busy_until: u64, -} - -impl Frame { - fn new(device: &Device, width: u32, height: u32) -> Option { - let shared = device.fences.is_some(); - let mut texture = None; - unsafe { device.device.CreateTexture2D(&texture_desc(width, height, shared), None, Some(&mut texture)) }.ok()?; - let texture = texture?; - let dxgi: IDXGISurface = texture.cast().ok()?; - let surface: IDirect3DSurface = unsafe { CreateDirect3D11SurfaceFromDXGISurface(&dxgi) }.ok()?.cast().ok()?; - let shared = shared - .then(|| { - let resource: IDXGIResource1 = texture.cast().ok()?; - let access = DXGI_SHARED_RESOURCE_READ.0 | DXGI_SHARED_RESOURCE_WRITE.0; - unsafe { resource.CreateSharedHandle(None, access, PCWSTR::null()) }.ok().map(SharedHandle::new) - }) - .flatten(); - Some(Frame { texture, surface, shared, busy_until: 0 }) - } -} - -/// A texture a consumer may still be reading is not written; with every other one busy, a decoded -/// frame is dropped rather than waited for. -const POOL_SIZE: usize = 3; - -#[derive(Default)] -struct Pool { - frames: Vec, - latest: Option, - width: u32, - height: u32, - fences: Option, - latest_ready: u64, - next_ready: u64, - next_release: u64, -} - -unsafe impl Send for Pool {} - -impl Pool { - fn latest(&self) -> Option<&Frame> { - self.frames.get(self.latest?) - } -} - -#[derive(Default)] -struct Frames { - pool: Mutex, - generation: AtomicU64, - /// Set under `pool`'s lock: a `VideoFrameAvailable` still running when its handler is removed - /// copies nothing after the bridge closes. - closed: AtomicBool, -} - -impl Frames { - /// On the player's thread, from `VideoFrameAvailable`. - fn copy_from(&self, player: &MediaPlayer) -> bool { - let Some(device) = device() else { return false }; - let Ok(session) = player.PlaybackSession() else { return false }; - let (width, height) = (session.NaturalVideoWidth().unwrap_or(0), session.NaturalVideoHeight().unwrap_or(0)); - if width == 0 || height == 0 { - return false; - } - let mut pool = self.pool.lock().unwrap(); - if self.closed.load(Ordering::Acquire) { - return false; - } - if pool.width != width || pool.height != height { - pool.frames.clear(); - pool.latest = None; - pool.width = width; - pool.height = height; - } - if pool.fences.is_none() { - pool.fences = device.fences.as_ref().and_then(|(device5, _)| Fences::new(device5)); - } - let released = pool.fences.as_ref().map_or(u64::MAX, |fences| unsafe { fences.release.GetCompletedValue() }); - let latest = pool.latest; - let free = |i: usize| Some(i) != latest && pool.frames.get(i).is_none_or(|frame| frame.busy_until <= released); - let Some(index) = (0..POOL_SIZE).find(|&i| free(i)) else { - return false; - }; - if index == pool.frames.len() { - let Some(frame) = Frame::new(device, width, height) else { return false }; - pool.frames.push(frame); - } - if player.CopyFrameToVideoSurface(&pool.frames[index].surface).is_err() { - return false; - } - if let (Some(fences), Some((_, context4))) = (pool.fences.as_ref(), device.fences.as_ref()) { - let value = pool.next_ready + 1; - if unsafe { context4.Signal(&fences.ready, value) }.is_ok() { - pool.next_ready = value; - } - } - pool.latest = Some(index); - pool.latest_ready = pool.next_ready; - self.generation.fetch_add(1, Ordering::AcqRel); - true - } - - fn close(&self) { - let _pool = self.pool.lock().unwrap(); - self.closed.store(true, Ordering::Release); - } - - fn size(&self) -> (u32, u32) { - let pool = self.pool.lock().unwrap(); - if pool.latest.is_some() { - (pool.width, pool.height) - } else { - (0, 0) - } - } -} - -struct XamlSurface { - native: ISurfaceImageSourceNative, - width: u32, - height: u32, -} - -impl XamlSurface { - /// UI thread. - fn present(&self, device: &Device, texture: &ID3D11Texture2D) -> windows::core::Result<()> { - let rect = RECT { left: 0, top: 0, right: self.width as i32, bottom: self.height as i32 }; - let mut surface = std::ptr::null_mut(); - let mut offset = POINT::default(); - unsafe { self.native.BeginDraw(rect, &mut surface, &mut offset) }.ok()?; - // The update rectangle lives in XAML's atlas at `offset`. - let copied = unsafe { IDXGISurface::from_raw(surface) }.cast::().map(|target| unsafe { - device.context.CopySubresourceRegion(&target, 0, offset.x.max(0) as u32, offset.y.max(0) as u32, 0, texture, 0, None) - }); - let ended = unsafe { self.native.EndDraw() }.ok(); - copied.and(ended) - } -} - -/// `address`: a `tap::AudioTapSource` for audiocontext.node. -#[napi(js_name = "NSCAudioTap")] -pub struct NSCAudioTap { - tap: Arc, - source: Box, -} - -#[napi] -impl NSCAudioTap { - #[napi(getter)] - pub fn address(&self) -> f64 { - &*self.source as *const tap::AudioTapSource as usize as f64 - } - - #[napi] - pub fn set_gain(&self, gain: f64) { - self.tap.set_gain(gain as f32); - } - - /// Routed: the player is silent and its audio goes to the graph. - #[napi] - pub fn set_routed(&self, routed: bool) { - self.tap.set_routed(routed); - } - - #[napi(getter)] - pub fn frames_tapped(&self) -> f64 { - self.tap.frames.load(std::sync::atomic::Ordering::Relaxed) as f64 - } -} - -/// Holds the pool, so the handles it names stay open. -#[napi(js_name = "NSCSharedFrame", custom_finalize)] -pub struct NSCSharedFrame { - desc: Box, - frames: Arc, - width: u32, - height: u32, - released: bool, -} - -impl ObjectFinalize for NSCSharedFrame { - fn finalize(mut self, _: Env) -> Result<()> { - self.close(); - Ok(()) - } -} - -#[napi] -impl NSCSharedFrame { - #[napi(getter)] - pub fn address(&self) -> f64 { - &*self.desc as *const SharedFrameDesc as usize as f64 - } - - #[napi(getter)] - pub fn width(&self) -> u32 { - self.width - } - - #[napi(getter)] - pub fn height(&self) -> u32 { - self.height - } - - /// An unconsumed frame (its import failed) frees its texture at once; a consumed one when the - /// consumer's release signal completes. - #[napi] - pub fn close(&mut self) { - if std::mem::replace(&mut self.released, true) { - return; - } - // Written by the consumer (another module) through the address. - let consumed = unsafe { std::ptr::read_volatile(&self.desc.consumed) }; - if consumed == 0 { - let mut pool = self.frames.pool.lock().unwrap(); - let (texture_id, release_value) = (self.desc.texture_id, self.desc.release_value); - if let Some(frame) = pool.frames.iter_mut().find(|f| f.shared.as_ref().is_some_and(|s| s.id == texture_id)) { - if frame.busy_until == release_value { - frame.busy_until = 0; - } - } - } - } -} - -/// What a bridge holds on the player. A MediaPlayer released while the process exits (after -/// ExitProcess has killed Media Foundation's threads, e.g. from a thread-local destructor) spins -/// forever in the graphics driver, so an env cleanup hook closes every live player while its threads -/// still run, and nothing here is released from process shutdown. -struct Live { - player: MediaPlayer, - unsubscribe: Vec>, - frames: Option>, -} - -impl Live { - fn close(&mut self) { - for unsubscribe in self.unsubscribe.drain(..) { - unsubscribe(); - } - if let Some(frames) = self.frames.as_ref() { - frames.close(); - } - } -} - -type LiveCell = RefCell>; - -thread_local! { - // Weak and ManuallyDrop: dropping these at thread exit releases nothing. - static LIVE: RefCell>> = const { RefCell::new(Vec::new()) }; - static TEST_PLAYERS: RefCell>> = const { RefCell::new(Vec::new()) }; - static CLEANUP_HOOKED: Cell = const { Cell::new(false) }; -} - -fn close_everything() { - for live in LIVE.with(|live| std::mem::take(&mut *live.borrow_mut())) { - if let Some(live) = live.upgrade() { - if let Some(mut live) = live.borrow_mut().take() { - live.close(); - let _ = live.player.Close(); - } - } - } - close_test_players(); -} - -fn ensure_cleanup_hook(env: &Env) -> Result<()> { - if !CLEANUP_HOOKED.get() { - env.add_env_cleanup_hook((), |_| close_everything())?; - CLEANUP_HOOKED.set(true); - } - Ok(()) -} - -type Emit = Arc) + Send + Sync>; -type EventCallback<'a> = Function<'a, FnArgs<(String, Option)>, UnknownReturnValue>; - -fn notify(emit: &Emit, kind: &'static str) -> TypedEventHandler { - let emit = Arc::clone(emit); - TypedEventHandler::new(move |_, _| { - emit(kind, None); - Ok(()) - }) -} - -/// MediaPlayer raises its events on Media Foundation threads, where the runtime cannot run JS -/// delegates: they are subscribed here and delivered on the JS thread. -#[napi(js_name = "NSCMediaPlayerBridge", custom_finalize)] -pub struct NSCMediaPlayerBridge { - live: Rc, - frames: Option>, - staging: RefCell>, - xaml: RefCell>, -} - -impl ObjectFinalize for NSCMediaPlayerBridge { - fn finalize(mut self, _: Env) -> Result<()> { - self.close(); - Ok(()) - } -} - -#[napi] -impl NSCMediaPlayerBridge { - /// `onEvent(type, detail)`: `opened`, `ended`, `error` (detail: the message), `state` (detail: the - /// `MediaPlaybackState`), `seeked`, `durationchange`, `resize`, `waiting`, `buffered` and, with - /// `frames`, `frame` (at most one queued). - #[napi( - constructor, - ts_args_type = "playerKey: string, onEvent: (type: string, detail?: string) => void, frames?: boolean" - )] - pub fn new(env: Env, player_key: String, on_event: EventCallback, frames: Option) -> Result { - ensure_cleanup_hook(&env)?; - let raw = parse_pointer_key(&player_key) - .ok_or_else(|| Error::from_reason(format!("Invalid MediaPlayer pointer: {player_key}")))?; - let player: MediaPlayer = unsafe { IUnknown::from_raw_borrowed(&raw) } - .ok_or_else(|| Error::from_reason("Invalid MediaPlayer pointer"))? - .cast() - .map_err(to_napi)?; - let session = player.PlaybackSession().map_err(to_napi)?; - - let frame_queued = Arc::new(AtomicBool::new(false)); - let queued = Arc::clone(&frame_queued); - let tsfn = on_event - .build_threadsafe_function::<(String, Option)>() - .weak::() - .build_callback(move |ctx| { - if ctx.value.0 == "frame" { - queued.store(false, Ordering::Release); - } - Ok(FnArgs::from(ctx.value)) - })?; - let emit: Emit = Arc::new(move |kind, detail| { - tsfn.call((kind.to_string(), detail), ThreadsafeFunctionCallMode::NonBlocking); - }); - - let mut unsubscribe: Vec> = Vec::new(); - macro_rules! subscribe { - ($target:expr, $add:ident, $remove:ident, $handler:expr) => {{ - let target = $target.clone(); - let token = target.$add(&$handler).map_err(to_napi)?; - unsubscribe.push(Box::new(move || { - let _ = target.$remove(token); - })); - }}; - } - type OnPlayer = IInspectable; - - subscribe!(player, MediaOpened, RemoveMediaOpened, notify::(&emit, "opened")); - subscribe!(player, MediaEnded, RemoveMediaEnded, notify::(&emit, "ended")); - let failed = { - let emit = Arc::clone(&emit); - TypedEventHandler::::new(move |_, args| { - let message = args.ok().ok().map(|args| { - let message = args.ErrorMessage().map(|m| m.to_string()).unwrap_or_default(); - if message.is_empty() { - let code = args.ExtendedErrorCode().map(|code| code.0).unwrap_or(0); - format!("Media playback failed (0x{:08x})", code as u32) - } else { - message - } - }); - emit("error", message); - Ok(()) - }) - }; - subscribe!(player, MediaFailed, RemoveMediaFailed, failed); - let state = { - let emit = Arc::clone(&emit); - TypedEventHandler::::new(move |session, _| { - let state = session.ok().ok().and_then(|s| s.PlaybackState().ok()).map(|s| s.0.to_string()); - emit("state", state); - Ok(()) - }) - }; - subscribe!(session, PlaybackStateChanged, RemovePlaybackStateChanged, state); - subscribe!(session, SeekCompleted, RemoveSeekCompleted, notify::(&emit, "seeked")); - subscribe!( - session, - NaturalDurationChanged, - RemoveNaturalDurationChanged, - notify::(&emit, "durationchange") - ); - subscribe!( - session, - NaturalVideoSizeChanged, - RemoveNaturalVideoSizeChanged, - notify::(&emit, "resize") - ); - subscribe!(session, BufferingStarted, RemoveBufferingStarted, notify::(&emit, "waiting")); - subscribe!(session, BufferingEnded, RemoveBufferingEnded, notify::(&emit, "buffered")); - - let frames = frames.unwrap_or(false).then(|| Arc::new(Frames::default())); - if let Some(frames) = frames.as_ref() { - let frames = Arc::clone(frames); - let emit = Arc::clone(&emit); - let available = TypedEventHandler::::new(move |player, _| { - if let Ok(player) = player.ok() { - if frames.copy_from(player) && !frame_queued.swap(true, Ordering::AcqRel) { - emit("frame", None); - } - } - Ok(()) - }); - subscribe!(player, VideoFrameAvailable, RemoveVideoFrameAvailable, available); - } - - let live = Rc::new(RefCell::new(Some(Live { player, unsubscribe, frames: frames.clone() }))); - LIVE.with(|all| { - let mut all = all.borrow_mut(); - all.retain(|live| live.strong_count() > 0); - all.push(Rc::downgrade(&live)); - }); - Ok(Self { live, frames, staging: RefCell::new(None), xaml: RefCell::new(None) }) - } - - /// The last frame stays readable. - #[napi] - pub fn close(&mut self) { - if let Some(mut live) = self.live.borrow_mut().take() { - live.close(); - } - self.xaml.borrow_mut().take(); - } - - #[napi(getter)] - pub fn video_width(&self) -> u32 { - self.frames.as_ref().map_or(0, |frames| frames.size().0) - } - - #[napi(getter)] - pub fn video_height(&self) -> u32 { - self.frames.as_ref().map_or(0, |frames| frames.size().1) - } - - #[napi(getter)] - pub fn adapter_luid(&self) -> f64 { - device().map_or(0., |device| device.luid as f64) - } - - #[napi(getter)] - pub fn shares_frames(&self) -> bool { - self.frames.is_some() && device().is_some_and(|device| device.fences.is_some()) - } - - /// `address` is a `CanvasD3DSharedFrame` for `nativeTexture`; the texture is not written again - /// until released. - #[napi] - pub fn gpu_frame(&self) -> Option { - let frames = self.frames.as_ref()?; - let device = device()?; - let mut pool = frames.pool.lock().unwrap(); - let index = pool.latest?; - let (width, height, ready_value) = (pool.width, pool.height, pool.latest_ready); - let fences = pool.fences.as_ref()?; - let (ready_fence_id, ready_fence) = (fences.ready_handle.id, fences.ready_handle.handle.0); - let (release_fence_id, release_fence) = (fences.release_handle.id, fences.release_handle.handle.0); - pool.next_release += 1; - let release_value = pool.next_release; - let frame = &mut pool.frames[index]; - let shared = frame.shared.as_ref()?; - let (texture_id, texture) = (shared.id, shared.handle.0); - frame.busy_until = frame.busy_until.max(release_value); - Some(NSCSharedFrame { - desc: Box::new(SharedFrameDesc { - size: std::mem::size_of::() as u32, - consumed: 0, - adapter_luid: device.luid, - texture_id, - texture, - ready_fence_id, - ready_fence, - ready_value, - release_fence_id, - release_fence, - release_value, - }), - frames: Arc::clone(frames), - width, - height, - released: false, - }) - } - - /// Passes the audio through until routed. Effects only apply to the sources set after them, so - /// this runs before any; optional, so a player whose effect cannot be activated just plays. - #[napi] - pub fn create_audio_tap(&self) -> Result { - let live = self.live.borrow(); - let player = &live.as_ref().ok_or_else(|| Error::from_reason("The player is closed"))?.player; - let tap = tap::Tap::new(); - let configuration = windows::Foundation::Collections::PropertySet::new().map_err(to_napi)?; - configuration - .Insert(&windows::core::HSTRING::from(tap::TAP_KEY), &windows::Foundation::PropertyValue::CreateUInt64(tap.id).map_err(to_napi)?) - .map_err(to_napi)?; - player - .AddAudioEffect(&windows::core::HSTRING::from(tap::TAP_CLASS), true, &configuration) - .map_err(to_napi)?; - let source = Box::new(tap::AudioTapSource::new(&tap)); - Ok(NSCAudioTap { tap, source }) - } - - #[napi(getter)] - pub fn frame_id(&self) -> f64 { - self.frames.as_ref().map_or(0., |frames| frames.generation.load(Ordering::Acquire) as f64) - } - - /// UI thread; replaces any previous one. - #[napi] - pub fn attach_surface_image_source(&self, key: String, width: u32, height: u32) -> bool { - let (Some(device), Some(raw)) = (device(), parse_pointer_key(&key)) else { return false }; - let attached = (|| -> windows::core::Result { - let unknown = unsafe { IUnknown::from_raw_borrowed(&raw) }.ok_or_else(windows::core::Error::empty)?; - let native: ISurfaceImageSourceNative = unknown.cast()?; - let dxgi: IDXGIDevice = device.device.cast()?; - unsafe { native.SetDevice(dxgi.as_raw()) }.ok()?; - Ok(XamlSurface { native, width, height }) - })(); - let attached = attached.ok(); - let ok = attached.is_some(); - *self.xaml.borrow_mut() = attached; - ok - } - - #[napi] - pub fn detach_surface_image_source(&self) { - self.xaml.borrow_mut().take(); - } - - /// UI thread. `false` without a surface of the frame's size. - #[napi] - pub fn present(&self) -> bool { - let (Some(frames), Some(device)) = (self.frames.as_ref(), device()) else { return false }; - let xaml = self.xaml.borrow(); - let Some(surface) = xaml.as_ref() else { return false }; - let pool = frames.pool.lock().unwrap(); - if pool.width != surface.width || pool.height != surface.height { - return false; - } - let Some(frame) = pool.latest() else { return false }; - surface.present(device, &frame.texture).is_ok() - } - - /// RGBA, top row first; `null` before the first frame. - #[napi] - pub fn read_pixels(&self) -> Option { - let (frames, device) = (self.frames.as_ref()?, device()?); - let mut staging = self.staging.borrow_mut(); - let pool = frames.pool.lock().unwrap(); - let frame = pool.latest()?; - let (width, height) = (pool.width, pool.height); - if staging.as_ref().is_none_or(|(_, w, h)| *w != width || *h != height) { - let desc = D3D11_TEXTURE2D_DESC { - Usage: D3D11_USAGE_STAGING, - BindFlags: 0, - CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, - ..texture_desc(width, height, false) - }; - let mut texture = None; - unsafe { device.device.CreateTexture2D(&desc, None, Some(&mut texture)) }.ok()?; - *staging = Some((texture?, width, height)); - } - let (texture, ..) = staging.as_ref()?; - unsafe { device.context.CopyResource(texture, &frame.texture) }; - drop(pool); - - let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); - unsafe { device.context.Map(texture, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) }.ok()?; - let row = width as usize * 4; - let mut rgba = vec![0u8; row * height as usize]; - for (y, dst) in rgba.chunks_exact_mut(row).enumerate() { - let src = unsafe { std::slice::from_raw_parts((mapped.pData as *const u8).add(y * mapped.RowPitch as usize), row) }; - for (dst, src) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { - dst.copy_from_slice(&[src[2], src[1], src[0], 255]); - } - } - unsafe { device.context.Unmap(texture, 0) }; - Some(Uint8Array::new(rgba)) - } -} - -/// A muted, autoplaying frame-server player for tests: Node has no WinRT projection to make one. -#[napi(js_name = "__createTestPlayer")] -pub fn create_test_player(env: Env, uri: String) -> Result { - ensure_cleanup_hook(&env)?; - let player = MediaPlayer::new().map_err(to_napi)?; - let setup = || -> windows::core::Result<()> { - player.SetIsVideoFrameServerEnabled(true)?; - player.SetIsMuted(true)?; - player.SetAutoPlay(true)?; - let uri = windows::Foundation::Uri::CreateUri(&windows::core::HSTRING::from(uri.as_str()))?; - player.SetSource(&windows::Media::Core::MediaSource::CreateFromUri(&uri)?) - }; - setup().map_err(to_napi)?; - let key = format!("0x{:x}", player.as_raw() as usize); - TEST_PLAYERS.with(|players| players.borrow_mut().push(ManuallyDrop::new(player))); - Ok(key) -} - -#[napi(js_name = "__closeTestPlayers")] -pub fn close_test_players() { - for player in TEST_PLAYERS.with(|players| std::mem::take(&mut *players.borrow_mut())) { - let player = ManuallyDrop::into_inner(player); - let _ = player.Close(); - } -} +#[cfg(target_os = "linux")] +mod linux; diff --git a/crates/canvas-media-napi/src/linux/frame.rs b/crates/canvas-media-napi/src/linux/frame.rs new file mode 100644 index 000000000..fb12168fe --- /dev/null +++ b/crates/canvas-media-napi/src/linux/frame.rs @@ -0,0 +1,654 @@ +//! Frames handed to canvas natively, as `nativeTexture` / `__texImageVideoFrame` addresses. +//! +//! GL frames are GStreamer textures: GL consumers (canvas WebGL, on GDK's EGL display) read them +//! through an EGLImage, WebGPU (Vulkan) consumers through `copy_to`, which blits the frame into +//! their exportable image here (`GL_EXT_memory_object_fd`), synchronized with their semaphores +//! (`GL_EXT_semaphore_fd`). Other frames are RGBA rows in mapped memory, uploaded once. + +use std::ffi::c_void; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; + +use gst_gl::prelude::*; +use gtk::gdk::prelude::GLContextExt as _; +use gtk::{gdk, glib}; +use napi::bindgen_prelude::ObjectFinalize; +use napi::{Env, Result}; +use napi_derive::napi; + +use super::gl::{self, gl, Gl}; +use super::Frames; + +/// Same layout as `CanvasLinuxVideoFrame` in canvas-c. +#[repr(C)] +pub struct LinuxVideoFrame { + pub size: u32, + /// `KIND_GL` or `KIND_MEMORY`. + pub kind: u32, + pub width: u32, + pub height: u32, + /// The player: consumers key their per-producer caches with it. + pub producer: u64, + pub egl_display: *mut c_void, + /// GL: the frame texture, owned by the producer. + pub egl_image: *mut c_void, + /// Changes only when `egl_image` does. + pub image_id: u64, + /// Memory: RGBA rows. + pub data: *const u8, + pub stride: u32, + pub reserved: u32, + /// GL: the GPU's (GL_EXT_memory_object), zeros if unknown. + pub device_uuid: [u8; 16], + /// GL: blits the frame into a consumer's Vulkan image. + pub copy_to: Option bool>, + /// GL: an EGLSync a GL consumer sets after its reads; the texture is reused once it signals. + pub release_sync: *mut c_void, + pub user: *mut c_void, +} + +pub const KIND_GL: u32 = 1; +pub const KIND_MEMORY: u32 = 2; + +/// Same layout as `CanvasLinuxFrameTarget` in canvas-c: an exportable RGBA8 Vulkan image. +#[repr(C)] +pub struct LinuxFrameTarget { + pub size: u32, + /// OPAQUE_FDs, sent the first time an id is used (the producer owns them), -1 after. + pub memory_fd: i32, + pub id: u64, + pub allocation_size: u64, + pub width: u32, + pub height: u32, + /// Signalled here once the image is written; -1 without semaphores (then finished here). + pub ready_fd: i32, + /// Signalled by the consumer once its reads are done. + pub release_fd: i32, + /// The release semaphore has a signal pending: wait for it before writing. + pub wait_release: u32, + pub reserved: u32, +} + +/// A Vulkan image imported into the GL share group (GDK's and GStreamer's contexts). +pub(super) struct Import { + id: u64, + memory: u32, + texture: u32, + ready: u32, + release: u32, + /// Framebuffers are per context: by EGL context. + framebuffers: Vec<(usize, u32)>, +} + +const MAX_IMPORTS: usize = 6; + +/// What a GL context offers consumers. +#[derive(Clone, Copy, Default)] +pub(super) struct Caps { + pub uuid: Option<[u8; 16]>, + pub semaphores: bool, +} + +static NEXT_IMAGE: AtomicU64 = AtomicU64::new(1); + +struct Image { + display: usize, + image: usize, + id: u64, +} + +unsafe extern "C" fn drop_image(data: *mut c_void) { + let image = unsafe { Box::from_raw(data as *mut Image) }; + if let Some(gl) = gl() { + unsafe { (gl.DestroyImageKHR)(image.display as *mut c_void, image.image as *mut c_void) }; + } +} + +fn image_quark() -> glib::ffi::GQuark { + static QUARK: OnceLock = OnceLock::new(); + *QUARK.get_or_init(|| unsafe { glib::ffi::g_quark_from_static_string(c"nsc-egl-image".as_ptr()) }) +} + +/// Sends what `thread_add` runs: it runs before returning, on GStreamer's GL thread. +struct OnGl(T); +unsafe impl Send for OnGl {} + +/// The memory's texture as an EGLImage, made once per pool texture and freed with it. +fn egl_image(memory: &gst_gl::GLMemoryRef, display: usize) -> Option<(usize, u64)> { + let object = memory.as_ptr() as *mut gst::ffi::GstMiniObject; + let existing = unsafe { gst::ffi::gst_mini_object_get_qdata(object, image_quark()) }; + if !existing.is_null() { + let image = unsafe { &*(existing as *const Image) }; + return Some((image.image, image.id)); + } + let gl = gl()?; + let texture = memory.texture_id(); + let mut image = 0usize; + let out = OnGl(&mut image as *mut usize); + memory.context().thread_add(move |_| { + let out = out; + unsafe { *out.0 = gl.texture_image(display as *mut c_void, texture) as usize }; + }); + if image == 0 { + return None; + } + let id = NEXT_IMAGE.fetch_add(1, Ordering::Relaxed); + let data = Box::into_raw(Box::new(Image { display, image, id })); + unsafe { + gst::ffi::gst_mini_object_set_qdata( + object, + image_quark(), + data as *mut c_void, + Some(drop_image), + ) + }; + Some((image, id)) +} + +/// GL thread: the context's interop support. +unsafe fn query_caps(gl: &Gl) -> Caps { + unsafe { + if !gl.has_extension("GL_EXT_memory_object_fd") { + return Caps::default(); + } + let mut uuid = [0u8; 16]; + while (gl.GetError)() != 0 {} + (gl.GetUnsignedBytevEXT)(gl::GL_DEVICE_UUID_EXT, uuid.as_mut_ptr()); + Caps { + uuid: ((gl.GetError)() == 0).then_some(uuid), + semaphores: gl.has_extension("GL_EXT_semaphore_fd"), + } + } +} + +pub(super) fn caps(frames: &Frames, context: &gst_gl::GLContext) -> Caps { + *frames.caps.get_or_init(|| { + let Some(gl) = gl() else { + return Caps::default(); + }; + let mut caps = Caps::default(); + let out = OnGl(&mut caps as *mut Caps); + context.thread_add(move |_| { + let out = out; + unsafe { *out.0 = query_caps(gl) }; + }); + caps + }) +} + +/// 53 bits of the device UUID, for JS (`supportsGPUFrames(device)`); 0 if unknown. +pub fn device_key(uuid: &[u8; 16]) -> f64 { + let mut key = 0u64; + for (i, byte) in uuid.iter().enumerate() { + key ^= (*byte as u64) << ((i % 7) * 8); + } + (key & ((1 << 53) - 1)) as f64 +} + +struct Held { + desc: LinuxVideoFrame, + sample: Option, + _mapped: Option>, + frames: Arc, +} + +/// A frame handed out: keep it until the consumer's call returned, then `close()`. +#[napi(js_name = "NSCVideoFrame", custom_finalize)] +pub struct NSCVideoFrame { + held: Option>, +} + +impl ObjectFinalize for NSCVideoFrame { + fn finalize(mut self, _: Env) -> Result<()> { + self.close(); + Ok(()) + } +} + +#[napi] +impl NSCVideoFrame { + /// The `CanvasLinuxVideoFrame`'s address. + #[napi(getter)] + pub fn address(&self) -> f64 { + self.held.as_ref().map_or(0., |held| { + &held.desc as *const LinuxVideoFrame as usize as f64 + }) + } + + #[napi(getter)] + pub fn width(&self) -> u32 { + self.held.as_ref().map_or(0, |held| held.desc.width) + } + + #[napi(getter)] + pub fn height(&self) -> u32 { + self.held.as_ref().map_or(0, |held| held.desc.height) + } + + /// `true` for a GL texture, `false` for mapped memory. + #[napi(getter)] + pub fn gpu(&self) -> bool { + self + .held + .as_ref() + .is_some_and(|held| held.desc.kind == KIND_GL) + } + + #[napi] + pub fn close(&mut self) { + let Some(held) = self.held.take() else { + return; + }; + let Held { + desc, + sample, + frames, + .. + } = *held; + match sample { + Some(sample) if !desc.release_sync.is_null() => { + frames.retire(sample, desc.release_sync as usize) + } + _ => {} + } + frames.reap(); + } +} + +impl NSCVideoFrame { + /// The latest frame: a GL texture when `gpu` allows and the frame is one, else mapped memory + /// (a GL frame is downloaded). + pub(super) fn latest(producer: u64, frames: &Arc, gpu: bool) -> Option { + let sample = frames + .sample + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone()?; + let info = sample + .caps() + .and_then(|caps| gst_video::VideoInfo::from_caps(caps).ok())?; + let mut desc = LinuxVideoFrame { + size: std::mem::size_of::() as u32, + kind: KIND_MEMORY, + width: info.width(), + height: info.height(), + producer, + egl_display: std::ptr::null_mut(), + egl_image: std::ptr::null_mut(), + image_id: 0, + data: std::ptr::null(), + stride: 0, + reserved: 0, + device_uuid: [0; 16], + copy_to: None, + release_sync: std::ptr::null_mut(), + user: std::ptr::null_mut(), + }; + let buffer = sample.buffer_owned()?; + if gpu { + let memory = buffer + .peek_memory(0) + .downcast_memory_ref::(); + if let (Some(memory), Some(share)) = (memory, gl::share()) { + if let Some((image, image_id)) = egl_image(memory, share.egl_display) { + let caps = caps(frames, memory.context()); + desc.kind = KIND_GL; + desc.egl_display = share.egl_display as *mut c_void; + desc.egl_image = image as *mut c_void; + desc.image_id = image_id; + if let Some(uuid) = caps.uuid { + desc.device_uuid = uuid; + desc.copy_to = Some(copy_to); + } + let mut held = Box::new(Held { + desc, + sample: Some(sample), + _mapped: None, + frames: Arc::clone(frames), + }); + held.desc.user = &*held as *const Held as *mut c_void; + return Some(Self { held: Some(held) }); + } + } + } + let mapped = gst_video::VideoFrame::from_buffer_readable(buffer, &info).ok()?; + desc.data = mapped.plane_data(0).ok()?.as_ptr(); + desc.stride = mapped.plane_stride()[0] as u32; + let mut held = Box::new(Held { + desc, + sample: None, + _mapped: Some(mapped), + frames: Arc::clone(frames), + }); + held.desc.user = &*held as *const Held as *mut c_void; + Some(Self { held: Some(held) }) + } +} + +impl Frames { + /// Keeps a sample a GL consumer read until its fence signals. + fn retire(&self, sample: gst::Sample, sync: usize) { + self + .retiring + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((sample, sync)); + } + + pub(super) fn reap(&self) { + let (Some(gl), Some(share)) = (gl(), gl::share_ready()) else { + return; + }; + let display = share.egl_display as *mut c_void; + self + .retiring + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|(_, sync)| !unsafe { gl.sync_done(display, *sync as *mut c_void) }); + } + + /// Frees the Vulkan images imported for consumers (UI thread: in GDK's context, which shares + /// them; framebuffers of GStreamer's context go with it). + pub(super) fn drop_imports(&self) { + let imports = std::mem::take(&mut *self.imports.lock().unwrap_or_else(|e| e.into_inner())); + let sources = std::mem::take( + &mut *self + .source_framebuffer + .lock() + .unwrap_or_else(|e| e.into_inner()), + ); + let (Some(gl), Some(context)) = (gl(), gl::gdk_context()) else { + return; + }; + if imports.is_empty() && sources.is_empty() { + return; + } + context.make_current(); + let current = unsafe { (gl.GetCurrentContext)() } as usize; + unsafe { + for import in &imports { + delete_import(gl, import, current); + } + for (owner, framebuffer) in sources { + if owner == current { + (gl.DeleteFramebuffers)(1, &framebuffer); + } + } + } + gdk::GLContext::clear_current(); + } +} + +/// With a context of the share group current; only its framebuffers can go. +unsafe fn delete_import(gl: &Gl, import: &Import, current: usize) { + unsafe { + for (owner, framebuffer) in &import.framebuffers { + if *owner == current { + (gl.DeleteFramebuffers)(1, framebuffer); + } + } + (gl.DeleteTextures)(1, &import.texture); + (gl.DeleteMemoryObjectsEXT)(1, &import.memory); + for semaphore in [import.ready, import.release] { + if semaphore != 0 { + (gl.DeleteSemaphoresEXT)(1, &semaphore); + } + } + } +} + +/// A share-group context current: the consumer's image, imported the first time (taking its fds). +unsafe fn import(gl: &Gl, target: &LinuxFrameTarget, semaphores: bool) -> Option { + if target.memory_fd < 0 { + return None; + } + unsafe { + while (gl.GetError)() != 0 {} + let mut memory = 0; + (gl.CreateMemoryObjectsEXT)(1, &mut memory); + (gl.MemoryObjectParameterivEXT)(memory, gl::GL_DEDICATED_MEMORY_OBJECT_EXT, &1); + (gl.ImportMemoryFdEXT)( + memory, + target.allocation_size, + gl::GL_HANDLE_TYPE_OPAQUE_FD_EXT, + target.memory_fd, + ); + let mut texture = 0; + (gl.GenTextures)(1, &mut texture); + (gl.BindTexture)(gl::GL_TEXTURE_2D, texture); + (gl.TexParameteri)( + gl::GL_TEXTURE_2D, + gl::GL_TEXTURE_TILING_EXT, + gl::GL_OPTIMAL_TILING_EXT, + ); + (gl.TexStorageMem2DEXT)( + gl::GL_TEXTURE_2D, + 1, + gl::GL_RGBA8, + target.width as i32, + target.height as i32, + memory, + 0, + ); + (gl.BindTexture)(gl::GL_TEXTURE_2D, 0); + let (mut ready, mut release) = (0, 0); + if semaphores && target.ready_fd >= 0 && target.release_fd >= 0 { + (gl.GenSemaphoresEXT)(1, &mut ready); + (gl.ImportSemaphoreFdEXT)(ready, gl::GL_HANDLE_TYPE_OPAQUE_FD_EXT, target.ready_fd); + (gl.GenSemaphoresEXT)(1, &mut release); + (gl.ImportSemaphoreFdEXT)(release, gl::GL_HANDLE_TYPE_OPAQUE_FD_EXT, target.release_fd); + } else { + for fd in [target.ready_fd, target.release_fd] { + if fd >= 0 { + libc_close(fd); + } + } + } + let import = Import { + id: target.id, + memory, + texture, + ready, + release, + framebuffers: Vec::new(), + }; + if (gl.GetError)() != 0 { + delete_import(gl, &import, 0); + eprintln!("canvas-media: could not import a WebGPU image into GStreamer's GL"); + return None; + } + Some(import) + } +} + +fn libc_close(fd: i32) { + extern "C" { + fn close(fd: i32) -> i32; + } + unsafe { close(fd) }; +} + +/// `texture` as a framebuffer of the current context, made once; 0 if incomplete. +unsafe fn framebuffer( + gl: &Gl, + framebuffers: &mut Vec<(usize, u32)>, + context: usize, + texture: u32, +) -> u32 { + if let Some((_, framebuffer)) = framebuffers.iter().find(|(owner, _)| *owner == context) { + return *framebuffer; + } + let mut framebuffer = 0; + unsafe { + (gl.GenFramebuffers)(1, &mut framebuffer); + (gl.BindFramebuffer)(gl::GL_DRAW_FRAMEBUFFER, framebuffer); + (gl.FramebufferTexture2D)( + gl::GL_DRAW_FRAMEBUFFER, + gl::GL_COLOR_ATTACHMENT0, + gl::GL_TEXTURE_2D, + texture, + 0, + ); + if (gl.CheckFramebufferStatus)(gl::GL_DRAW_FRAMEBUFFER) != gl::GL_FRAMEBUFFER_COMPLETE { + (gl.DeleteFramebuffers)(1, &framebuffer); + return 0; + } + } + framebuffers.push((context, framebuffer)); + framebuffer +} + +/// A share-group context current: the frame texture into the consumer's image. +unsafe fn blit(gl: &Gl, frames: &Frames, texture: u32, target: &LinuxFrameTarget) -> bool { + let context = unsafe { (gl.GetCurrentContext)() } as usize; + let semaphores = frames.caps.get().is_some_and(|caps| caps.semaphores); + let mut imports = frames.imports.lock().unwrap_or_else(|e| e.into_inner()); + let index = match imports.iter().position(|import| import.id == target.id) { + Some(index) => index, + None => { + let Some(import) = (unsafe { import(gl, target, semaphores) }) else { + return false; + }; + if imports.len() >= MAX_IMPORTS { + let old = imports.remove(0); + unsafe { delete_import(gl, &old, context) }; + } + imports.push(import); + imports.len() - 1 + } + }; + let import = &mut imports[index]; + unsafe { + while (gl.GetError)() != 0 {} + let (mut read, mut draw) = (0, 0); + (gl.GetIntegerv)(gl::GL_READ_FRAMEBUFFER_BINDING, &mut read); + (gl.GetIntegerv)(gl::GL_DRAW_FRAMEBUFFER_BINDING, &mut draw); + let scissor = (gl.IsEnabled)(gl::GL_SCISSOR_TEST) != 0; + if import.release != 0 && target.wait_release != 0 { + let layout = gl::GL_LAYOUT_SHADER_READ_ONLY_EXT; + (gl.WaitSemaphoreEXT)( + import.release, + 0, + std::ptr::null(), + 1, + &import.texture, + &layout, + ); + } + let destination = framebuffer(gl, &mut import.framebuffers, context, import.texture); + if destination == 0 { + (gl.BindFramebuffer)(gl::GL_DRAW_FRAMEBUFFER, draw as u32); + eprintln!("canvas-media: a WebGPU image is not renderable in GL"); + return false; + } + let source = { + let mut sources = frames + .source_framebuffer + .lock() + .unwrap_or_else(|e| e.into_inner()); + match sources.iter().find(|(owner, _)| *owner == context) { + Some((_, source)) => *source, + None => { + let mut source = 0; + (gl.GenFramebuffers)(1, &mut source); + sources.push((context, source)); + source + } + } + }; + (gl.BindFramebuffer)(gl::GL_READ_FRAMEBUFFER, source); + (gl.FramebufferTexture2D)( + gl::GL_READ_FRAMEBUFFER, + gl::GL_COLOR_ATTACHMENT0, + gl::GL_TEXTURE_2D, + texture, + 0, + ); + (gl.BindFramebuffer)(gl::GL_DRAW_FRAMEBUFFER, destination); + (gl.Disable)(gl::GL_SCISSOR_TEST); + let (width, height) = (target.width as i32, target.height as i32); + (gl.BlitFramebuffer)( + 0, + 0, + width, + height, + 0, + 0, + width, + height, + gl::GL_COLOR_BUFFER_BIT, + gl::GL_NEAREST, + ); + (gl.FramebufferTexture2D)( + gl::GL_READ_FRAMEBUFFER, + gl::GL_COLOR_ATTACHMENT0, + gl::GL_TEXTURE_2D, + 0, + 0, + ); + if scissor { + (gl.Enable)(gl::GL_SCISSOR_TEST); + } + (gl.BindFramebuffer)(gl::GL_READ_FRAMEBUFFER, read as u32); + (gl.BindFramebuffer)(gl::GL_DRAW_FRAMEBUFFER, draw as u32); + if import.ready != 0 { + let layout = gl::GL_LAYOUT_SHADER_READ_ONLY_EXT; + (gl.SignalSemaphoreEXT)( + import.ready, + 0, + std::ptr::null(), + 1, + &import.texture, + &layout, + ); + (gl.Flush)(); + } else { + // No semaphores: Vulkan reads it after this returns. + (gl.Finish)(); + } + (gl.GetError)() == 0 + } +} + +unsafe extern "C" fn copy_to(frame: *mut LinuxVideoFrame, target: *const LinuxFrameTarget) -> bool { + let (Some(frame), Some(target)) = (unsafe { frame.as_ref() }, unsafe { target.as_ref() }) else { + return false; + }; + if target.size as usize != std::mem::size_of::() + || (target.width, target.height) != (frame.width, frame.height) + || frame.user.is_null() + { + return false; + } + let held = unsafe { &*(frame.user as *const Held) }; + let (Some(sample), Some(gl)) = (held.sample.as_ref(), gl()) else { + return false; + }; + let Some(memory) = sample.buffer().and_then(|buffer| { + buffer + .peek_memory(0) + .downcast_memory_ref::() + }) else { + return false; + }; + let texture = memory.texture_id(); + // UI thread: GDK's context shares the texture, without waiting for GStreamer's GL thread. + if let Some(gdk) = gl::gdk_context() { + gdk.make_current(); + let done = unsafe { blit(gl, &held.frames, texture, target) }; + gdk::GLContext::clear_current(); + return done; + } + let mut done = false; + let job = OnGl(( + &mut done as *mut bool, + &*held.frames as *const Frames, + target as *const LinuxFrameTarget, + )); + memory.context().thread_add(move |_| { + let job = job; + let (done, frames, target) = job.0; + unsafe { *done = blit(gl, &*frames, texture, &*target) }; + }); + done +} diff --git a/crates/canvas-media-napi/src/linux/gl.rs b/crates/canvas-media-napi/src/linux/gl.rs new file mode 100644 index 000000000..f5fc57118 --- /dev/null +++ b/crates/canvas-media-napi/src/linux/gl.rs @@ -0,0 +1,301 @@ +//! GL frames: GStreamer's GL context shares GDK's (one EGL display), so a decoded frame stays a +//! texture from upload to display, and other GL / Vulkan users get it on the GPU. + +use std::cell::OnceCell; +use std::ffi::{c_char, c_void, CStr}; +use std::sync::OnceLock; + +use gst_gl::prelude::*; +use gtk::gdk; +use gtk::gdk::prelude::{DisplayExt as _, GLContextExt as _}; + +pub const GL_TEXTURE_2D: u32 = 0x0DE1; +pub const GL_RGBA8: u32 = 0x8058; +pub const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5; +pub const GL_READ_FRAMEBUFFER: u32 = 0x8CA8; +pub const GL_DRAW_FRAMEBUFFER: u32 = 0x8CA9; +pub const GL_READ_FRAMEBUFFER_BINDING: u32 = 0x8CAA; +pub const GL_DRAW_FRAMEBUFFER_BINDING: u32 = 0x8CA6; +pub const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0; +pub const GL_COLOR_BUFFER_BIT: u32 = 0x4000; +pub const GL_NEAREST: u32 = 0x2600; +pub const GL_SCISSOR_TEST: u32 = 0x0C11; +pub const GL_EXTENSIONS: u32 = 0x1F03; +pub const GL_NUM_EXTENSIONS: u32 = 0x821D; +pub const GL_TEXTURE_TILING_EXT: u32 = 0x9580; +pub const GL_DEDICATED_MEMORY_OBJECT_EXT: u32 = 0x9581; +pub const GL_OPTIMAL_TILING_EXT: i32 = 0x9584; +pub const GL_HANDLE_TYPE_OPAQUE_FD_EXT: u32 = 0x9586; +pub const GL_DEVICE_UUID_EXT: u32 = 0x9597; +pub const GL_LAYOUT_SHADER_READ_ONLY_EXT: u32 = 0x9591; + +const EGL_GL_TEXTURE_2D_KHR: u32 = 0x30B1; +const EGL_GL_TEXTURE_LEVEL_KHR: i32 = 0x30BC; +const EGL_IMAGE_PRESERVED_KHR: i32 = 0x30D2; +const EGL_NONE: i32 = 0x3038; +const EGL_SYNC_STATUS_KHR: i32 = 0x30F1; +const EGL_SIGNALED_KHR: i32 = 0x30F2; + +/// EGL and GL entry points (any current context of the driver). +#[allow(non_snake_case)] +pub struct Gl { + pub GetCurrentDisplay: unsafe extern "C" fn() -> *mut c_void, + pub GetCurrentContext: unsafe extern "C" fn() -> *mut c_void, + pub CreateImageKHR: + unsafe extern "C" fn(*mut c_void, *mut c_void, u32, *mut c_void, *const i32) -> *mut c_void, + pub DestroyImageKHR: unsafe extern "C" fn(*mut c_void, *mut c_void) -> u32, + pub GetSyncAttribKHR: unsafe extern "C" fn(*mut c_void, *mut c_void, i32, *mut i32) -> u32, + pub DestroySyncKHR: unsafe extern "C" fn(*mut c_void, *mut c_void) -> u32, + + pub GetError: unsafe extern "C" fn() -> u32, + pub GetIntegerv: unsafe extern "C" fn(u32, *mut i32), + pub GetStringi: unsafe extern "C" fn(u32, u32) -> *const c_char, + pub IsEnabled: unsafe extern "C" fn(u32) -> u8, + pub Enable: unsafe extern "C" fn(u32), + pub Disable: unsafe extern "C" fn(u32), + pub Flush: unsafe extern "C" fn(), + pub Finish: unsafe extern "C" fn(), + pub GenTextures: unsafe extern "C" fn(i32, *mut u32), + pub DeleteTextures: unsafe extern "C" fn(i32, *const u32), + pub BindTexture: unsafe extern "C" fn(u32, u32), + pub TexParameteri: unsafe extern "C" fn(u32, u32, i32), + pub GenFramebuffers: unsafe extern "C" fn(i32, *mut u32), + pub DeleteFramebuffers: unsafe extern "C" fn(i32, *const u32), + pub BindFramebuffer: unsafe extern "C" fn(u32, u32), + pub FramebufferTexture2D: unsafe extern "C" fn(u32, u32, u32, u32, i32), + pub CheckFramebufferStatus: unsafe extern "C" fn(u32) -> u32, + pub BlitFramebuffer: unsafe extern "C" fn(i32, i32, i32, i32, i32, i32, i32, i32, u32, u32), + + pub CreateMemoryObjectsEXT: unsafe extern "C" fn(i32, *mut u32), + pub DeleteMemoryObjectsEXT: unsafe extern "C" fn(i32, *const u32), + pub MemoryObjectParameterivEXT: unsafe extern "C" fn(u32, u32, *const i32), + pub ImportMemoryFdEXT: unsafe extern "C" fn(u32, u64, u32, i32), + pub TexStorageMem2DEXT: unsafe extern "C" fn(u32, i32, u32, i32, i32, u32, u64), + pub GetUnsignedBytevEXT: unsafe extern "C" fn(u32, *mut u8), + pub GenSemaphoresEXT: unsafe extern "C" fn(i32, *mut u32), + pub DeleteSemaphoresEXT: unsafe extern "C" fn(i32, *const u32), + pub ImportSemaphoreFdEXT: unsafe extern "C" fn(u32, u32, i32), + pub WaitSemaphoreEXT: unsafe extern "C" fn(u32, u32, *const u32, u32, *const u32, *const u32), + pub SignalSemaphoreEXT: unsafe extern "C" fn(u32, u32, *const u32, u32, *const u32, *const u32), +} + +static GL: OnceLock> = OnceLock::new(); + +pub fn gl() -> Option<&'static Gl> { + GL.get_or_init(|| unsafe { load() }).as_ref() +} + +unsafe fn load() -> Option { + let lib = unsafe { libloading::Library::new("libEGL.so.1") }.ok()?; + let get: libloading::Symbol *mut c_void> = + unsafe { lib.get(b"eglGetProcAddress\0") }.ok()?; + let get = *get; + // Stays loaded: the entry points live in it. + std::mem::forget(lib); + macro_rules! f { + ($name:literal) => {{ + let address = unsafe { get(concat!($name, "\0").as_ptr() as *const c_char) }; + if address.is_null() { + return None; + } + unsafe { std::mem::transmute::<*mut c_void, _>(address) } + }}; + } + Some(Gl { + GetCurrentDisplay: f!("eglGetCurrentDisplay"), + GetCurrentContext: f!("eglGetCurrentContext"), + CreateImageKHR: f!("eglCreateImageKHR"), + DestroyImageKHR: f!("eglDestroyImageKHR"), + GetSyncAttribKHR: f!("eglGetSyncAttribKHR"), + DestroySyncKHR: f!("eglDestroySyncKHR"), + GetError: f!("glGetError"), + GetIntegerv: f!("glGetIntegerv"), + GetStringi: f!("glGetStringi"), + IsEnabled: f!("glIsEnabled"), + Enable: f!("glEnable"), + Disable: f!("glDisable"), + Flush: f!("glFlush"), + Finish: f!("glFinish"), + GenTextures: f!("glGenTextures"), + DeleteTextures: f!("glDeleteTextures"), + BindTexture: f!("glBindTexture"), + TexParameteri: f!("glTexParameteri"), + GenFramebuffers: f!("glGenFramebuffers"), + DeleteFramebuffers: f!("glDeleteFramebuffers"), + BindFramebuffer: f!("glBindFramebuffer"), + FramebufferTexture2D: f!("glFramebufferTexture2D"), + CheckFramebufferStatus: f!("glCheckFramebufferStatus"), + BlitFramebuffer: f!("glBlitFramebuffer"), + CreateMemoryObjectsEXT: f!("glCreateMemoryObjectsEXT"), + DeleteMemoryObjectsEXT: f!("glDeleteMemoryObjectsEXT"), + MemoryObjectParameterivEXT: f!("glMemoryObjectParameterivEXT"), + ImportMemoryFdEXT: f!("glImportMemoryFdEXT"), + TexStorageMem2DEXT: f!("glTexStorageMem2DEXT"), + GetUnsignedBytevEXT: f!("glGetUnsignedBytevEXT"), + GenSemaphoresEXT: f!("glGenSemaphoresEXT"), + DeleteSemaphoresEXT: f!("glDeleteSemaphoresEXT"), + ImportSemaphoreFdEXT: f!("glImportSemaphoreFdEXT"), + WaitSemaphoreEXT: f!("glWaitSemaphoreEXT"), + SignalSemaphoreEXT: f!("glSignalSemaphoreEXT"), + }) +} + +impl Gl { + /// The current context's extensions (GL 3 / GLES 3). + pub unsafe fn has_extension(&self, name: &str) -> bool { + let mut count = 0; + unsafe { (self.GetIntegerv)(GL_NUM_EXTENSIONS, &mut count) }; + (0..count.max(0) as u32).any(|i| { + let extension = unsafe { (self.GetStringi)(GL_EXTENSIONS, i) }; + !extension.is_null() && unsafe { CStr::from_ptr(extension) }.to_bytes() == name.as_bytes() + }) + } + + /// `texture` (of the current context's share group) as an EGLImage. + pub unsafe fn texture_image(&self, display: *mut c_void, texture: u32) -> *mut c_void { + let attributes = [ + EGL_GL_TEXTURE_LEVEL_KHR, + 0, + EGL_IMAGE_PRESERVED_KHR, + 1, + EGL_NONE, + ]; + unsafe { + (self.CreateImageKHR)( + display, + (self.GetCurrentContext)(), + EGL_GL_TEXTURE_2D_KHR, + texture as usize as *mut c_void, + attributes.as_ptr(), + ) + } + } + + /// A consumer's EGL fence signalled (or gone); destroys it then. + pub unsafe fn sync_done(&self, display: *mut c_void, sync: *mut c_void) -> bool { + let mut status = 0; + let known = unsafe { (self.GetSyncAttribKHR)(display, sync, EGL_SYNC_STATUS_KHR, &mut status) }; + let done = known == 0 || status == EGL_SIGNALED_KHR; + if done { + unsafe { (self.DestroySyncKHR)(display, sync) }; + } + done + } +} + +/// What the streaming threads need to put GStreamer's GL on GDK's display. +pub struct Share { + pub display: gst_gl::GLDisplay, + /// GDK's context, wrapped: GStreamer's shares with it. + pub app: gst_gl::GLContext, + pub egl_display: usize, +} + +static SHARE: OnceLock> = OnceLock::new(); + +thread_local! { + /// The GDK context frames are shown with (UI thread). + static GDK: OnceCell = const { OnceCell::new() }; +} + +/// `NS_CANVAS_MEDIA_CPU=1`: system-memory frames only. +pub fn forced_cpu() -> bool { + std::env::var_os("NS_CANVAS_MEDIA_CPU").is_some_and(|v| !v.is_empty() && v != "0") +} + +/// UI thread: the GL share, set up once. None without GDK EGL (GLX, no GL) or when forced off. +pub fn share() -> Option<&'static Share> { + SHARE + .get_or_init(|| { + if forced_cpu() { + return None; + } + let share = unsafe { init() }; + if share.is_none() { + eprintln!("canvas-media: GDK has no shareable EGL context; video frames stay in memory"); + } + share + }) + .as_ref() +} + +/// The share if it was set up, without setting it up (any thread). +pub fn share_ready() -> Option<&'static Share> { + SHARE.get().and_then(Option::as_ref) +} + +pub fn gdk_context() -> Option { + GDK.with(|gdk| gdk.get().cloned()) +} + +unsafe fn init() -> Option { + let gl = gl()?; + let context = gdk::Display::default()?.create_gl_context().ok()?; + context.realize().ok()?; + context.make_current(); + let egl_display = unsafe { (gl.GetCurrentDisplay)() }; + let egl_context = unsafe { (gl.GetCurrentContext)() }; + let share = (|| { + if egl_display.is_null() || egl_context.is_null() { + return None; + } + let api = if context.uses_es() { + gst_gl::GLAPI::GLES2 + } else { + gst_gl::GLAPI::OPENGL3 + }; + let display = unsafe { gst_gl_egl::GLDisplayEGL::with_egl_display(egl_display as usize) } + .ok()? + .upcast::(); + let app = unsafe { + gst_gl::GLContext::new_wrapped(&display, egl_context as usize, gst_gl::GLPlatform::EGL, api) + }?; + app.activate(true).ok()?; + let filled = app.fill_info(); + let _ = app.activate(false); + filled.ok()?; + Some(Share { + display, + app, + egl_display: egl_display as usize, + }) + })(); + gdk::GLContext::clear_current(); + if share.is_some() { + GDK.with(|gdk| { + let _ = gdk.set(context); + }); + } + share +} + +/// Answers GStreamer GL's `need-context` with GDK's display and context. +pub fn answer_need_context(share: &Share, message: &gst::Message) { + let gst::MessageView::NeedContext(need) = message.view() else { + return; + }; + let Some(element) = message + .src() + .and_then(|src| src.downcast_ref::()) + else { + return; + }; + let context = match need.context_type() { + t if t == gst_gl::GL_DISPLAY_CONTEXT_TYPE.as_str() => { + let context = gst::Context::new(t, true); + context.set_gl_display(&share.display); + context + } + "gst.gl.app_context" => { + let mut context = gst::Context::new("gst.gl.app_context", true); + context + .get_mut() + .unwrap() + .structure_mut() + .set("context", &share.app); + context + } + _ => return, + }; + element.set_context(&context); +} diff --git a/crates/canvas-media-napi/src/linux/mod.rs b/crates/canvas-media-napi/src/linux/mod.rs new file mode 100644 index 000000000..314d982c1 --- /dev/null +++ b/crates/canvas-media-napi/src/linux/mod.rs @@ -0,0 +1,647 @@ +//! Linux: the player behind @nativescript/canvas-media's Video and Audio views, on GStreamer. +//! +//! `playbin` picks the decoders (VA-API on Intel/AMD with gstreamer-vaapi) and the audio sink. +//! Video takes the GL path when GDK is on EGL (`gl`): `glupload ! glcolorconvert` to RGBA +//! textures in a context sharing GDK's, shown as GdkGLTextures and handed to canvas on the GPU +//! (`frame`). Otherwise (`NS_CANVAS_MEDIA_CPU=1`, GLX, missing GL elements, a GL error before the +//! first frame) `videoconvert` makes RGBA in memory, shown as GdkMemoryTextures. +//! +//! Decoders offering DMABuf (GStreamer >= 1.24 `va`) are imported by `glupload` without a copy. +//! TODO: GdkDmabufTexture (GTK >= 4.14) display and NV12 DMABuf import into Vulkan. + +mod frame; +mod gl; + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use gst::prelude::*; +use gst_video::prelude::*; +use gtk::{gdk, glib}; +use napi::bindgen_prelude::{FnArgs, Function, ObjectFinalize, Uint8Array}; +use napi::threadsafe_function::{ + ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue, +}; +use napi::{Env, Error, Result}; +use napi_derive::napi; + +pub use frame::NSCVideoFrame; + +type EventCallback<'a> = Function<'a, FnArgs<(String, Option)>, UnknownReturnValue>; +type Emitter = ThreadsafeFunction< + (String, Option), + UnknownReturnValue, + FnArgs<(String, Option)>, + napi::Status, + false, + true, +>; + +/// `MediaPlaybackState` values, as the Windows bridge reports them. +const STATE_PLAYING: &str = "3"; +const STATE_PAUSED: &str = "4"; + +/// What the streaming thread shares with the UI thread. +#[derive(Default)] +struct Frames { + sample: Mutex>, + id: AtomicU64, + width: AtomicU32, + height: AtomicU32, + /// A present is queued on the UI thread. + queued: AtomicBool, + /// The video size changed since the last present. + resized: AtomicBool, + /// Samples GL consumers read, with their EGL fences. + retiring: Mutex>, + imports: Mutex>, + /// Framebuffers reading frames, by EGL context. + source_framebuffer: Mutex>, + caps: OnceLock, +} + +static NEXT_PLAYER: AtomicU64 = AtomicU64::new(1); + +/// `GST_DEBUG=canvasmedia:4` logs the frame path. +static CAT: std::sync::LazyLock = std::sync::LazyLock::new(|| { + gst::DebugCategory::new( + "canvasmedia", + gst::DebugColorFlags::empty(), + Some("canvas-media"), + ) +}); + +thread_local! { + /// Pictures by player (UI thread): frames arrive from the streaming thread by id. + static PICTURES: RefCell, Arc)>> = RefCell::new(HashMap::new()); +} + +/// GTK-rs wants to have initialized GTK itself; the app already has. +fn gtk_ready() -> bool { + if !gtk::is_initialized() && unsafe { gtk::ffi::gtk_is_initialized() } != 0 { + unsafe { gtk::set_initialized() }; + } + gtk::is_initialized_main_thread() +} + +/// UI thread: the latest frame as a texture wrapping the decoded buffer. +fn present(id: u64, frames: &Frames) { + frames.queued.store(false, Ordering::Release); + let Some(sample) = frames + .sample + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + else { + return; + }; + let Some(info) = sample + .caps() + .and_then(|caps| gst_video::VideoInfo::from_caps(caps).ok()) + else { + return; + }; + let Some(texture) = gl_texture(&sample, &info).or_else(|| memory_texture(sample, &info)) else { + return; + }; + PICTURES.with(|pictures| { + if let Some((picture, emit)) = pictures.borrow().get(&id) { + if let Some(picture) = picture.upgrade() { + picture.set_paintable(Some(&texture)); + } + if frames.resized.swap(false, Ordering::AcqRel) { + emit.call( + (String::from("resize"), None), + ThreadsafeFunctionCallMode::NonBlocking, + ); + } + emit.call( + (String::from("frame"), None), + ThreadsafeFunctionCallMode::NonBlocking, + ); + } + }); +} + +/// A GL frame as a texture of GDK's, holding the sample until GDK lets go. +fn gl_texture(sample: &gst::Sample, info: &gst_video::VideoInfo) -> Option { + let memory = sample + .buffer()? + .peek_memory(0) + .downcast_memory_ref::()?; + let context = gl::gdk_context()?; + let texture = memory.texture_id(); + let sample = sample.clone(); + let texture = unsafe { + gdk::GLTexture::with_release_func( + &context, + texture, + info.width() as i32, + info.height() as i32, + move || drop(sample), + ) + }; + Some(texture.upcast()) +} + +/// A system-memory frame, wrapped without a copy (GDK uploads it). +fn memory_texture(sample: gst::Sample, info: &gst_video::VideoInfo) -> Option { + let mapped = sample.buffer_owned()?.into_mapped_buffer_readable().ok()?; + let bytes = glib::Bytes::from_owned(mapped); + Some( + gdk::MemoryTexture::new( + info.width() as i32, + info.height() as i32, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + info.stride()[0] as usize, + ) + .upcast(), + ) +} + +/// GL: RGBA textures; system memory and DMABuf inputs (VA-API's GL texture upload meta fails here). +const GL_SINK: &str = "capsfilter caps=\"video/x-raw(memory:GLMemory);video/x-raw(memory:DMABuf);video/x-raw\" ! glupload ! glcolorconvert ! video/x-raw(memory:GLMemory),format=RGBA,texture-target=2D ! appsink name=sink sync=true max-buffers=1 drop=true enable-last-sample=false"; +const CPU_SINK: &str = "videoconvert ! video/x-raw,format=RGBA ! appsink name=sink sync=true max-buffers=1 drop=true enable-last-sample=false"; + +/// The video sink bin: the latest frame, dropping late ones. +fn video_sink(id: u64, frames: &Arc, gpu: bool) -> Result { + let bin = gst::parse::bin_from_description(if gpu { GL_SINK } else { CPU_SINK }, true) + .map_err(|e| Error::from_reason(e.to_string()))?; + let sink = bin + .by_name("sink") + .and_then(|sink| sink.downcast::().ok()) + .ok_or_else(|| Error::from_reason("no appsink"))?; + let frames = Arc::clone(frames); + let take = Arc::new(move |sample: gst::Sample| { + // Consumers (GDK, canvas) read it in other contexts: it must be complete. + if let Some(meta) = sample + .buffer() + .and_then(|buffer| buffer.meta::()) + { + meta.wait_cpu(meta.context()); + } + if let Some(info) = sample + .caps() + .and_then(|caps| gst_video::VideoInfo::from_caps(caps).ok()) + { + let width = frames.width.swap(info.width(), Ordering::Relaxed); + let height = frames.height.swap(info.height(), Ordering::Relaxed); + if (width, height) != (info.width(), info.height()) { + frames.resized.store(true, Ordering::Release); + } + } + *frames.sample.lock().unwrap_or_else(|e| e.into_inner()) = Some(sample.clone()); + if frames.id.fetch_add(1, Ordering::AcqRel) == 0 { + gst::info!( + CAT, + "player {id}: {} frames, {:?}", + if sample + .buffer() + .is_some_and(|b| b.peek_memory(0).is_memory_type::()) + { + "GL texture" + } else { + "system memory" + }, + sample.caps() + ); + } + frames.reap(); + if !frames.queued.swap(true, Ordering::AcqRel) { + let frames = Arc::clone(&frames); + glib::MainContext::default().invoke(move || present(id, &frames)); + } + }); + let preroll = Arc::clone(&take); + sink.set_callbacks( + gst_app::AppSinkCallbacks::builder() + // The first frame shows (and is readable) before playing. + .new_preroll(move |sink| { + preroll(sink.pull_preroll().map_err(|_| gst::FlowError::Eos)?); + Ok(gst::FlowSuccess::Ok) + }) + .new_sample(move |sink| { + take(sink.pull_sample().map_err(|_| gst::FlowError::Eos)?); + Ok(gst::FlowSuccess::Ok) + }) + .build(), + ); + Ok(bin.upcast()) +} + +/// After a GL error before any frame: the same source again, with the system-memory sink. +fn fall_back_to_memory(playbin: &gst::Element, id: u64, frames: &Arc) { + let (_, current, pending) = playbin.state(gst::ClockTime::ZERO); + let target = if pending == gst::State::VoidPending { + current + } else { + pending + }; + let _ = playbin.set_state(gst::State::Null); + let Ok(sink) = video_sink(id, frames, false) else { + return; + }; + playbin.set_property("video-sink", sink); + let _ = playbin.set_state(target.max(gst::State::Paused)); +} + +/// `CanvasModule`-free media player: a `playbin`, its events on the JS thread and, for video, a +/// GtkPicture showing it. +#[napi(js_name = "NSCMediaPlayer", custom_finalize)] +pub struct NSCMediaPlayer { + id: u64, + playbin: gst::Element, + picture: Option, + frames: Arc, + looping: Arc, + /// Video frames are GL textures. + gpu: Rc>, + _bus: Option, +} + +impl ObjectFinalize for NSCMediaPlayer { + fn finalize(mut self, _: Env) -> Result<()> { + self.close(); + Ok(()) + } +} + +#[napi] +impl NSCMediaPlayer { + /// `onEvent(type, detail)`: `opened`, `ended`, `error` (detail: the message), `state` (detail: + /// 3 playing, 4 paused), `seeked`, `durationchange`, `resize` and, with `video`, `frame`. + #[napi( + constructor, + ts_args_type = "onEvent: (type: string, detail?: string) => void, video?: boolean" + )] + pub fn new(on_event: EventCallback, video: Option) -> Result { + gst::init().map_err(|e| Error::from_reason(format!("GStreamer: {e}")))?; + let playbin = gst::ElementFactory::make("playbin").build().map_err(|_| { + Error::from_reason("GStreamer's playbin is missing (gstreamer1.0-plugins-base)") + })?; + // gstreamer-vaapi's postprocessor hands out NV12 that cannot be mapped (iHD): decode only. + playbin.connect("element-setup", false, |values| { + if let Some(element) = values.get(1).and_then(|v| v.get::().ok()) { + if element + .factory() + .is_some_and(|factory| factory.name() == "vaapidecodebin") + { + element.set_property("disable-vpp", true); + } + } + None + }); + let emit: Arc = Arc::new( + on_event + .build_threadsafe_function::<(String, Option)>() + .weak::() + .build_callback(|ctx| Ok(FnArgs::from(ctx.value)))?, + ); + let id = NEXT_PLAYER.fetch_add(1, Ordering::Relaxed); + let frames = Arc::new(Frames::default()); + let video = video.unwrap_or(false) && gtk_ready(); + let gpu = Rc::new(Cell::new(video && gl::share().is_some())); + let picture = if video { + let sink = video_sink(id, &frames, gpu.get()).or_else(|error| { + if !gpu.replace(false) { + return Err(error); + } + eprintln!("canvas-media: no GStreamer GL elements ({error}); video frames stay in memory"); + video_sink(id, &frames, false) + })?; + playbin.set_property("video-sink", sink); + let picture = gtk::Picture::new(); + picture.set_can_shrink(true); + PICTURES.with(|pictures| { + pictures + .borrow_mut() + .insert(id, (picture.downgrade(), emit.clone())) + }); + Some(picture) + } else { + // Audio only: no video decoding at all. + playbin.set_property( + "video-sink", + gst::ElementFactory::make("fakesink").build().ok(), + ); + None + }; + let looping = Arc::new(AtomicBool::new(false)); + if gpu.get() { + if let Some(bus) = playbin.bus() { + // Synchronous: GL elements wait for the answer. + bus.set_sync_handler(|_, message| { + if let Some(share) = gl::share_ready() { + gl::answer_need_context(share, message); + } + gst::BusSyncReply::Pass + }); + } + } + let bus = playbin.bus().and_then(|bus| { + let weak = playbin.downgrade(); + let looping = Arc::clone(&looping); + let gpu = Rc::clone(&gpu); + let frames = Arc::clone(&frames); + let mut opened = false; + bus + .add_watch_local(move |_, message| { + let Some(playbin) = weak.upgrade() else { + return glib::ControlFlow::Break; + }; + let send = |kind: &str, detail: Option| { + emit.call( + (kind.to_string(), detail), + ThreadsafeFunctionCallMode::NonBlocking, + ); + }; + use gst::MessageView; + match message.view() { + MessageView::Eos(_) => { + if looping.load(Ordering::Relaxed) { + let _ = playbin.seek_simple( + gst::SeekFlags::FLUSH | gst::SeekFlags::KEY_UNIT, + gst::ClockTime::ZERO, + ); + } else { + send("ended", None); + } + } + MessageView::Error(error) => { + let in_sink = playbin + .property::>("video-sink") + .zip(error.src()) + .is_some_and(|(sink, src)| { + src == sink.upcast_ref::() || src.has_as_ancestor(&sink) + }); + if gpu.get() && in_sink && frames.id.load(Ordering::Acquire) == 0 { + eprintln!( + "canvas-media: GL video failed ({}); video frames stay in memory", + error.error() + ); + gpu.set(false); + fall_back_to_memory(&playbin, id, &frames); + } else { + send("error", Some(error.error().to_string())) + } + } + MessageView::StateChanged(change) if change.src() == Some(playbin.upcast_ref()) => { + match change.current() { + gst::State::Playing => send("state", Some(STATE_PLAYING.into())), + gst::State::Paused if change.old() == gst::State::Playing => { + send("state", Some(STATE_PAUSED.into())) + } + _ => {} + } + } + MessageView::AsyncDone(_) => { + if opened { + send("seeked", None); + } else { + opened = true; + send("opened", None); + } + } + MessageView::DurationChanged(_) => send("durationchange", None), + MessageView::StreamStart(_) => opened = false, + _ => {} + } + glib::ControlFlow::Continue + }) + .ok() + }); + Ok(Self { + id, + playbin, + picture, + frames, + looping, + gpu, + _bus: bus, + }) + } + + /// The latest frame for canvas (`NSCVideoFrame`): a GL texture when `gpu` (default true) and + /// the frame is one, else mapped RGBA rows. Null before the first frame. + #[napi(ts_return_type = "NSCVideoFrame | null")] + pub fn gpu_frame(&self, gpu: Option) -> Option { + NSCVideoFrame::latest(self.id, &self.frames, gpu.unwrap_or(true) && self.gpu.get()) + } + + /// Frames are GL textures. + #[napi(getter)] + pub fn gpu_frames(&self) -> bool { + self.gpu.get() + } + + /// 53 bits of the GL device UUID GL frames are on (`supportsGPUFrames`); 0 if unknown. + #[napi(getter)] + pub fn device_key(&self) -> f64 { + if !self.gpu.get() { + return 0.; + } + let caps = match self.frames.caps.get() { + Some(caps) => *caps, + None => { + let sample = self + .frames + .sample + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let Some(context) = sample.as_ref().and_then(|sample| { + Some( + sample + .buffer()? + .peek_memory(0) + .downcast_memory_ref::()? + .context() + .clone(), + ) + }) else { + return 0.; + }; + frame::caps(&self.frames, &context) + } + }; + caps.uuid.as_ref().map_or(0., frame::device_key) + } + + /// The GtkPicture's address (video players), for `new interop.Pointer(widget)`. + #[napi(getter)] + pub fn widget(&self) -> f64 { + self + .picture + .as_ref() + .map_or(0., |picture| picture.as_ptr() as usize as f64) + } + + /// A path or URI; empty unloads. + #[napi] + pub fn set_source(&self, source: String) { + let _ = self.playbin.set_state(gst::State::Null); + self.frames.id.store(0, Ordering::Release); + *self.frames.sample.lock().unwrap_or_else(|e| e.into_inner()) = None; + if source.is_empty() { + return; + } + let uri = if source.contains("://") { + source + } else { + match glib::filename_to_uri(&source, None) { + Ok(uri) => uri.to_string(), + Err(_) => return, + } + }; + self.playbin.set_property("uri", uri); + // Prerolls: `opened` follows with the first frame decoded. + let _ = self.playbin.set_state(gst::State::Paused); + } + + #[napi] + pub fn play(&self) -> bool { + self.playbin.set_state(gst::State::Playing).is_ok() + } + + #[napi] + pub fn pause(&self) { + let _ = self.playbin.set_state(gst::State::Paused); + } + + /// Seconds. + #[napi(getter)] + pub fn current_time(&self) -> f64 { + self + .playbin + .query_position::() + .map_or(0., |t| t.nseconds() as f64 / 1e9) + } + + #[napi(setter)] + pub fn set_current_time(&self, seconds: f64) { + if seconds.is_finite() && seconds >= 0. { + let _ = self.playbin.seek_simple( + gst::SeekFlags::FLUSH | gst::SeekFlags::ACCURATE, + gst::ClockTime::from_nseconds((seconds * 1e9) as u64), + ); + } + } + + /// Seconds, NaN until known. + #[napi(getter)] + pub fn duration(&self) -> f64 { + self + .playbin + .query_duration::() + .map_or(f64::NAN, |t| t.nseconds() as f64 / 1e9) + } + + #[napi(getter)] + pub fn volume(&self) -> f64 { + self.playbin.property::("volume") + } + + #[napi(setter)] + pub fn set_volume(&self, volume: f64) { + self.playbin.set_property("volume", volume.clamp(0., 1.)); + } + + #[napi(getter)] + pub fn muted(&self) -> bool { + self.playbin.property::("mute") + } + + #[napi(setter)] + pub fn set_muted(&self, muted: bool) { + self.playbin.set_property("mute", muted); + } + + #[napi(getter, js_name = "loop")] + pub fn looping(&self) -> bool { + self.looping.load(Ordering::Relaxed) + } + + #[napi(setter, js_name = "loop")] + pub fn set_looping(&self, looping: bool) { + self.looping.store(looping, Ordering::Relaxed); + } + + #[napi(getter)] + pub fn video_width(&self) -> u32 { + self.frames.width.load(Ordering::Relaxed) + } + + #[napi(getter)] + pub fn video_height(&self) -> u32 { + self.frames.height.load(Ordering::Relaxed) + } + + /// Changes with every decoded frame; 0 before the first. + #[napi(getter)] + pub fn frame_id(&self) -> f64 { + self.frames.id.load(Ordering::Acquire) as f64 + } + + /// Copies the latest frame into `out` (RGBA rows of `videoWidth * 4` bytes, the page's + /// buffer, reused across frames). `false` without a frame or when `out` is too small. + #[napi] + pub fn read_pixels_into(&self, mut out: Uint8Array) -> bool { + let Some(sample) = self + .frames + .sample + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + else { + return false; + }; + let Some(info) = sample + .caps() + .and_then(|caps| gst_video::VideoInfo::from_caps(caps).ok()) + else { + return false; + }; + let Some(buffer) = sample.buffer() else { + return false; + }; + let Ok(frame) = gst_video::VideoFrameRef::from_buffer_ref_readable(buffer, &info) else { + return false; + }; + let (width, height) = (info.width() as usize, info.height() as usize); + let row = width * 4; + let stride = frame.plane_stride()[0] as usize; + let (Ok(data), out) = (frame.plane_data(0), unsafe { out.as_mut() }) else { + return false; + }; + if out.len() < row * height { + return false; + } + if stride == row { + out[..row * height].copy_from_slice(&data[..row * height]); + } else { + for y in 0..height { + out[y * row..(y + 1) * row].copy_from_slice(&data[y * stride..y * stride + row]); + } + } + true + } + + #[napi] + pub fn close(&mut self) { + let _ = self.playbin.set_state(gst::State::Null); + self._bus = None; + self.frames.drop_imports(); + self + .frames + .retiring + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clear(); + PICTURES.with(|pictures| pictures.borrow_mut().remove(&self.id)); + self.picture = None; + } +} diff --git a/crates/canvas-media-napi/src/windows.rs b/crates/canvas-media-napi/src/windows.rs new file mode 100644 index 000000000..e828316b4 --- /dev/null +++ b/crates/canvas-media-napi/src/windows.rs @@ -0,0 +1,793 @@ +#[path = "tap.rs"] +mod tap; + +use std::cell::{Cell, RefCell}; +use std::mem::ManuallyDrop; +use std::rc::{Rc, Weak}; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use napi::bindgen_prelude::{FnArgs, Function, ObjectFinalize, Uint8Array}; +use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; +use napi::{Env, Error, Result}; +use napi_derive::napi; +use windows::core::{IInspectable, IUnknown, Interface, RuntimeType, HRESULT}; +use windows::Foundation::TypedEventHandler; +use windows::Graphics::DirectX::Direct3D11::IDirect3DSurface; +use windows::Media::Playback::{MediaPlaybackSession, MediaPlayer, MediaPlayerFailedEventArgs}; +use windows::core::PCWSTR; +use windows::Win32::Foundation::{CloseHandle, GENERIC_ALL, HANDLE, HMODULE, POINT, RECT}; +use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP}; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence, + ID3D11Multithread, ID3D11Resource, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, + D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_FENCE_FLAG_SHARED, + D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_RESOURCE_MISC_SHARED, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, + D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING, +}; +use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC}; +use windows::Win32::Graphics::Dxgi::{ + IDXGIDevice, IDXGIResource1, IDXGISurface, DXGI_SHARED_RESOURCE_READ, DXGI_SHARED_RESOURCE_WRITE, +}; +use windows::Win32::System::WinRT::Direct3D11::CreateDirect3D11SurfaceFromDXGISurface; + +/// WinUI 3's `ISurfaceImageSourceNative` (microsoft.ui.xaml.media.dxinterop.h; not the UWP IID). +#[windows::core::interface("e4cecd6c-f14b-4f46-83c3-8bbda27c6504")] +unsafe trait ISurfaceImageSourceNative: windows::core::IUnknown { + fn SetDevice(&self, device: *mut c_void) -> HRESULT; + fn BeginDraw(&self, update_rect: RECT, surface: *mut *mut c_void, offset: *mut POINT) -> HRESULT; + fn EndDraw(&self) -> HRESULT; +} + +fn parse_pointer_key(key: &str) -> Option<*mut c_void> { + let key = key.trim(); + let address = match key.strip_prefix("0x").or_else(|| key.strip_prefix("0X")) { + Some(hex) => usize::from_str_radix(hex, 16).ok(), + None => key.parse().ok(), + }?; + (address != 0).then_some(address as *mut c_void) +} + +fn to_napi(error: windows::core::Error) -> Error { + Error::from_reason(error.message()) +} + +struct Device { + device: ID3D11Device, + context: ID3D11DeviceContext, + /// WebGPU shares frames only on the same adapter. + luid: u64, + /// Windows 10 1703+; without fences frames are not shared. + fences: Option<(ID3D11Device5, ID3D11DeviceContext4)>, +} + +// Multithread-protected (below), and D3D11 devices are free-threaded. +unsafe impl Send for Device {} +unsafe impl Sync for Device {} + +/// One device for every player. MediaPlayer copies frames into it on its own threads while the JS +/// thread reads them back or presents them, so its immediate context is multithread-protected. +fn device() -> Option<&'static Device> { + static DEVICE: OnceLock> = OnceLock::new(); + DEVICE + .get_or_init(|| create_device(D3D_DRIVER_TYPE_HARDWARE).or_else(|| create_device(D3D_DRIVER_TYPE_WARP))) + .as_ref() +} + +fn create_device(driver: D3D_DRIVER_TYPE) -> Option { + let (mut device, mut context) = (None, None); + unsafe { + D3D11CreateDevice( + None, + driver, + HMODULE::default(), + // BGRA for XAML SurfaceImageSources; video support for MediaPlayer's video processor. + D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT, + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + } + .ok()?; + let (device, context) = (device?, context?); + let multithread: ID3D11Multithread = context.cast().ok()?; + let _ = unsafe { multithread.SetMultithreadProtected(true) }; + let luid = (|| -> windows::core::Result { + let adapter = unsafe { device.cast::()?.GetAdapter()? }; + let luid = unsafe { adapter.GetDesc()? }.AdapterLuid; + Ok(luid.LowPart as u64 | ((luid.HighPart as u32 as u64) << 32)) + })() + .unwrap_or(0); + let fences = device.cast::().ok().zip(context.cast::().ok()); + Some(Device { device, context, luid, fences }) +} + +fn texture_desc(width: u32, height: u32, shared: bool) -> D3D11_TEXTURE2D_DESC { + D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + // Opened by WebGPU on its D3D12 device (canvas-c's gpu_shared_frame). + MiscFlags: if shared { (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0 | D3D11_RESOURCE_MISC_SHARED.0) as u32 } else { 0 }, + } +} + +static NEXT_SHARED_ID: AtomicU64 = AtomicU64::new(1); + +struct SharedHandle { + handle: HANDLE, + /// Unique for the process's lifetime, unlike handle values: consumers cache by it. + id: u64, +} + +impl SharedHandle { + fn new(handle: HANDLE) -> Self { + Self { handle, id: NEXT_SHARED_ID.fetch_add(1, Ordering::Relaxed) } + } +} + +impl Drop for SharedHandle { + fn drop(&mut self) { + let _ = unsafe { CloseHandle(self.handle) }; + } +} + +struct Fences { + ready: ID3D11Fence, + ready_handle: SharedHandle, + release: ID3D11Fence, + release_handle: SharedHandle, +} + +impl Fences { + fn new(device: &ID3D11Device5) -> Option { + let create = || -> windows::core::Result<(ID3D11Fence, SharedHandle)> { + let mut fence: Option = None; + unsafe { device.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence)? }; + let fence = fence.ok_or_else(windows::core::Error::empty)?; + let handle = unsafe { fence.CreateSharedHandle(None, GENERIC_ALL.0, PCWSTR::null())? }; + Ok((fence, SharedHandle::new(handle))) + }; + let (ready, ready_handle) = create().ok()?; + let (release, release_handle) = create().ok()?; + Some(Fences { ready, ready_handle, release, release_handle }) + } +} + +/// `CanvasD3DSharedFrame` in canvas-c (crates/canvas-c/src/webgpu/gpu_shared_frame.rs): same layout. +#[repr(C)] +struct SharedFrameDesc { + size: u32, + consumed: u32, + adapter_luid: u64, + texture_id: u64, + texture: *mut c_void, + ready_fence_id: u64, + ready_fence: *mut c_void, + ready_value: u64, + release_fence_id: u64, + release_fence: *mut c_void, + release_value: u64, +} + +struct Frame { + texture: ID3D11Texture2D, + surface: IDirect3DSurface, + shared: Option, + /// The release value to wait for before writing the texture again. + busy_until: u64, +} + +impl Frame { + fn new(device: &Device, width: u32, height: u32) -> Option { + let shared = device.fences.is_some(); + let mut texture = None; + unsafe { device.device.CreateTexture2D(&texture_desc(width, height, shared), None, Some(&mut texture)) }.ok()?; + let texture = texture?; + let dxgi: IDXGISurface = texture.cast().ok()?; + let surface: IDirect3DSurface = unsafe { CreateDirect3D11SurfaceFromDXGISurface(&dxgi) }.ok()?.cast().ok()?; + let shared = shared + .then(|| { + let resource: IDXGIResource1 = texture.cast().ok()?; + let access = DXGI_SHARED_RESOURCE_READ.0 | DXGI_SHARED_RESOURCE_WRITE.0; + unsafe { resource.CreateSharedHandle(None, access, PCWSTR::null()) }.ok().map(SharedHandle::new) + }) + .flatten(); + Some(Frame { texture, surface, shared, busy_until: 0 }) + } +} + +/// A texture a consumer may still be reading is not written; with every other one busy, a decoded +/// frame is dropped rather than waited for. +const POOL_SIZE: usize = 3; + +#[derive(Default)] +struct Pool { + frames: Vec, + latest: Option, + width: u32, + height: u32, + fences: Option, + latest_ready: u64, + next_ready: u64, + next_release: u64, +} + +unsafe impl Send for Pool {} + +impl Pool { + fn latest(&self) -> Option<&Frame> { + self.frames.get(self.latest?) + } +} + +#[derive(Default)] +struct Frames { + pool: Mutex, + generation: AtomicU64, + /// Set under `pool`'s lock: a `VideoFrameAvailable` still running when its handler is removed + /// copies nothing after the bridge closes. + closed: AtomicBool, +} + +impl Frames { + /// On the player's thread, from `VideoFrameAvailable`. + fn copy_from(&self, player: &MediaPlayer) -> bool { + let Some(device) = device() else { return false }; + let Ok(session) = player.PlaybackSession() else { return false }; + let (width, height) = (session.NaturalVideoWidth().unwrap_or(0), session.NaturalVideoHeight().unwrap_or(0)); + if width == 0 || height == 0 { + return false; + } + let mut pool = self.pool.lock().unwrap(); + if self.closed.load(Ordering::Acquire) { + return false; + } + if pool.width != width || pool.height != height { + pool.frames.clear(); + pool.latest = None; + pool.width = width; + pool.height = height; + } + if pool.fences.is_none() { + pool.fences = device.fences.as_ref().and_then(|(device5, _)| Fences::new(device5)); + } + let released = pool.fences.as_ref().map_or(u64::MAX, |fences| unsafe { fences.release.GetCompletedValue() }); + let latest = pool.latest; + let free = |i: usize| Some(i) != latest && pool.frames.get(i).is_none_or(|frame| frame.busy_until <= released); + let Some(index) = (0..POOL_SIZE).find(|&i| free(i)) else { + return false; + }; + if index == pool.frames.len() { + let Some(frame) = Frame::new(device, width, height) else { return false }; + pool.frames.push(frame); + } + if player.CopyFrameToVideoSurface(&pool.frames[index].surface).is_err() { + return false; + } + if let (Some(fences), Some((_, context4))) = (pool.fences.as_ref(), device.fences.as_ref()) { + let value = pool.next_ready + 1; + if unsafe { context4.Signal(&fences.ready, value) }.is_ok() { + pool.next_ready = value; + } + } + pool.latest = Some(index); + pool.latest_ready = pool.next_ready; + self.generation.fetch_add(1, Ordering::AcqRel); + true + } + + fn close(&self) { + let _pool = self.pool.lock().unwrap(); + self.closed.store(true, Ordering::Release); + } + + fn size(&self) -> (u32, u32) { + let pool = self.pool.lock().unwrap(); + if pool.latest.is_some() { + (pool.width, pool.height) + } else { + (0, 0) + } + } +} + +struct XamlSurface { + native: ISurfaceImageSourceNative, + width: u32, + height: u32, +} + +impl XamlSurface { + /// UI thread. + fn present(&self, device: &Device, texture: &ID3D11Texture2D) -> windows::core::Result<()> { + let rect = RECT { left: 0, top: 0, right: self.width as i32, bottom: self.height as i32 }; + let mut surface = std::ptr::null_mut(); + let mut offset = POINT::default(); + unsafe { self.native.BeginDraw(rect, &mut surface, &mut offset) }.ok()?; + // The update rectangle lives in XAML's atlas at `offset`. + let copied = unsafe { IDXGISurface::from_raw(surface) }.cast::().map(|target| unsafe { + device.context.CopySubresourceRegion(&target, 0, offset.x.max(0) as u32, offset.y.max(0) as u32, 0, texture, 0, None) + }); + let ended = unsafe { self.native.EndDraw() }.ok(); + copied.and(ended) + } +} + +/// `address`: a `tap::AudioTapSource` for audiocontext.node. +#[napi(js_name = "NSCAudioTap")] +pub struct NSCAudioTap { + tap: Arc, + source: Box, +} + +#[napi] +impl NSCAudioTap { + #[napi(getter)] + pub fn address(&self) -> f64 { + &*self.source as *const tap::AudioTapSource as usize as f64 + } + + #[napi] + pub fn set_gain(&self, gain: f64) { + self.tap.set_gain(gain as f32); + } + + /// Routed: the player is silent and its audio goes to the graph. + #[napi] + pub fn set_routed(&self, routed: bool) { + self.tap.set_routed(routed); + } + + #[napi(getter)] + pub fn frames_tapped(&self) -> f64 { + self.tap.frames.load(std::sync::atomic::Ordering::Relaxed) as f64 + } +} + +/// Holds the pool, so the handles it names stay open. +#[napi(js_name = "NSCSharedFrame", custom_finalize)] +pub struct NSCSharedFrame { + desc: Box, + frames: Arc, + width: u32, + height: u32, + released: bool, +} + +impl ObjectFinalize for NSCSharedFrame { + fn finalize(mut self, _: Env) -> Result<()> { + self.close(); + Ok(()) + } +} + +#[napi] +impl NSCSharedFrame { + #[napi(getter)] + pub fn address(&self) -> f64 { + &*self.desc as *const SharedFrameDesc as usize as f64 + } + + #[napi(getter)] + pub fn width(&self) -> u32 { + self.width + } + + #[napi(getter)] + pub fn height(&self) -> u32 { + self.height + } + + /// An unconsumed frame (its import failed) frees its texture at once; a consumed one when the + /// consumer's release signal completes. + #[napi] + pub fn close(&mut self) { + if std::mem::replace(&mut self.released, true) { + return; + } + // Written by the consumer (another module) through the address. + let consumed = unsafe { std::ptr::read_volatile(&self.desc.consumed) }; + if consumed == 0 { + let mut pool = self.frames.pool.lock().unwrap(); + let (texture_id, release_value) = (self.desc.texture_id, self.desc.release_value); + if let Some(frame) = pool.frames.iter_mut().find(|f| f.shared.as_ref().is_some_and(|s| s.id == texture_id)) { + if frame.busy_until == release_value { + frame.busy_until = 0; + } + } + } + } +} + +/// What a bridge holds on the player. A MediaPlayer released while the process exits (after +/// ExitProcess has killed Media Foundation's threads, e.g. from a thread-local destructor) spins +/// forever in the graphics driver, so an env cleanup hook closes every live player while its threads +/// still run, and nothing here is released from process shutdown. +struct Live { + player: MediaPlayer, + unsubscribe: Vec>, + frames: Option>, +} + +impl Live { + fn close(&mut self) { + for unsubscribe in self.unsubscribe.drain(..) { + unsubscribe(); + } + if let Some(frames) = self.frames.as_ref() { + frames.close(); + } + } +} + +type LiveCell = RefCell>; + +thread_local! { + // Weak and ManuallyDrop: dropping these at thread exit releases nothing. + static LIVE: RefCell>> = const { RefCell::new(Vec::new()) }; + static TEST_PLAYERS: RefCell>> = const { RefCell::new(Vec::new()) }; + static CLEANUP_HOOKED: Cell = const { Cell::new(false) }; +} + +fn close_everything() { + for live in LIVE.with(|live| std::mem::take(&mut *live.borrow_mut())) { + if let Some(live) = live.upgrade() { + if let Some(mut live) = live.borrow_mut().take() { + live.close(); + let _ = live.player.Close(); + } + } + } + close_test_players(); +} + +fn ensure_cleanup_hook(env: &Env) -> Result<()> { + if !CLEANUP_HOOKED.get() { + env.add_env_cleanup_hook((), |_| close_everything())?; + CLEANUP_HOOKED.set(true); + } + Ok(()) +} + +type Emit = Arc) + Send + Sync>; +type EventCallback<'a> = Function<'a, FnArgs<(String, Option)>, UnknownReturnValue>; + +fn notify(emit: &Emit, kind: &'static str) -> TypedEventHandler { + let emit = Arc::clone(emit); + TypedEventHandler::new(move |_, _| { + emit(kind, None); + Ok(()) + }) +} + +/// MediaPlayer raises its events on Media Foundation threads, where the runtime cannot run JS +/// delegates: they are subscribed here and delivered on the JS thread. +#[napi(js_name = "NSCMediaPlayerBridge", custom_finalize)] +pub struct NSCMediaPlayerBridge { + live: Rc, + frames: Option>, + staging: RefCell>, + xaml: RefCell>, +} + +impl ObjectFinalize for NSCMediaPlayerBridge { + fn finalize(mut self, _: Env) -> Result<()> { + self.close(); + Ok(()) + } +} + +#[napi] +impl NSCMediaPlayerBridge { + /// `onEvent(type, detail)`: `opened`, `ended`, `error` (detail: the message), `state` (detail: the + /// `MediaPlaybackState`), `seeked`, `durationchange`, `resize`, `waiting`, `buffered` and, with + /// `frames`, `frame` (at most one queued). + #[napi( + constructor, + ts_args_type = "playerKey: string, onEvent: (type: string, detail?: string) => void, frames?: boolean" + )] + pub fn new(env: Env, player_key: String, on_event: EventCallback, frames: Option) -> Result { + ensure_cleanup_hook(&env)?; + let raw = parse_pointer_key(&player_key) + .ok_or_else(|| Error::from_reason(format!("Invalid MediaPlayer pointer: {player_key}")))?; + let player: MediaPlayer = unsafe { IUnknown::from_raw_borrowed(&raw) } + .ok_or_else(|| Error::from_reason("Invalid MediaPlayer pointer"))? + .cast() + .map_err(to_napi)?; + let session = player.PlaybackSession().map_err(to_napi)?; + + let frame_queued = Arc::new(AtomicBool::new(false)); + let queued = Arc::clone(&frame_queued); + let tsfn = on_event + .build_threadsafe_function::<(String, Option)>() + .weak::() + .build_callback(move |ctx| { + if ctx.value.0 == "frame" { + queued.store(false, Ordering::Release); + } + Ok(FnArgs::from(ctx.value)) + })?; + let emit: Emit = Arc::new(move |kind, detail| { + tsfn.call((kind.to_string(), detail), ThreadsafeFunctionCallMode::NonBlocking); + }); + + let mut unsubscribe: Vec> = Vec::new(); + macro_rules! subscribe { + ($target:expr, $add:ident, $remove:ident, $handler:expr) => {{ + let target = $target.clone(); + let token = target.$add(&$handler).map_err(to_napi)?; + unsubscribe.push(Box::new(move || { + let _ = target.$remove(token); + })); + }}; + } + type OnPlayer = IInspectable; + + subscribe!(player, MediaOpened, RemoveMediaOpened, notify::(&emit, "opened")); + subscribe!(player, MediaEnded, RemoveMediaEnded, notify::(&emit, "ended")); + let failed = { + let emit = Arc::clone(&emit); + TypedEventHandler::::new(move |_, args| { + let message = args.ok().ok().map(|args| { + let message = args.ErrorMessage().map(|m| m.to_string()).unwrap_or_default(); + if message.is_empty() { + let code = args.ExtendedErrorCode().map(|code| code.0).unwrap_or(0); + format!("Media playback failed (0x{:08x})", code as u32) + } else { + message + } + }); + emit("error", message); + Ok(()) + }) + }; + subscribe!(player, MediaFailed, RemoveMediaFailed, failed); + let state = { + let emit = Arc::clone(&emit); + TypedEventHandler::::new(move |session, _| { + let state = session.ok().ok().and_then(|s| s.PlaybackState().ok()).map(|s| s.0.to_string()); + emit("state", state); + Ok(()) + }) + }; + subscribe!(session, PlaybackStateChanged, RemovePlaybackStateChanged, state); + subscribe!(session, SeekCompleted, RemoveSeekCompleted, notify::(&emit, "seeked")); + subscribe!( + session, + NaturalDurationChanged, + RemoveNaturalDurationChanged, + notify::(&emit, "durationchange") + ); + subscribe!( + session, + NaturalVideoSizeChanged, + RemoveNaturalVideoSizeChanged, + notify::(&emit, "resize") + ); + subscribe!(session, BufferingStarted, RemoveBufferingStarted, notify::(&emit, "waiting")); + subscribe!(session, BufferingEnded, RemoveBufferingEnded, notify::(&emit, "buffered")); + + let frames = frames.unwrap_or(false).then(|| Arc::new(Frames::default())); + if let Some(frames) = frames.as_ref() { + let frames = Arc::clone(frames); + let emit = Arc::clone(&emit); + let available = TypedEventHandler::::new(move |player, _| { + if let Ok(player) = player.ok() { + if frames.copy_from(player) && !frame_queued.swap(true, Ordering::AcqRel) { + emit("frame", None); + } + } + Ok(()) + }); + subscribe!(player, VideoFrameAvailable, RemoveVideoFrameAvailable, available); + } + + let live = Rc::new(RefCell::new(Some(Live { player, unsubscribe, frames: frames.clone() }))); + LIVE.with(|all| { + let mut all = all.borrow_mut(); + all.retain(|live| live.strong_count() > 0); + all.push(Rc::downgrade(&live)); + }); + Ok(Self { live, frames, staging: RefCell::new(None), xaml: RefCell::new(None) }) + } + + /// The last frame stays readable. + #[napi] + pub fn close(&mut self) { + if let Some(mut live) = self.live.borrow_mut().take() { + live.close(); + } + self.xaml.borrow_mut().take(); + } + + #[napi(getter)] + pub fn video_width(&self) -> u32 { + self.frames.as_ref().map_or(0, |frames| frames.size().0) + } + + #[napi(getter)] + pub fn video_height(&self) -> u32 { + self.frames.as_ref().map_or(0, |frames| frames.size().1) + } + + #[napi(getter)] + pub fn adapter_luid(&self) -> f64 { + device().map_or(0., |device| device.luid as f64) + } + + #[napi(getter)] + pub fn shares_frames(&self) -> bool { + self.frames.is_some() && device().is_some_and(|device| device.fences.is_some()) + } + + /// `address` is a `CanvasD3DSharedFrame` for `nativeTexture`; the texture is not written again + /// until released. + #[napi] + pub fn gpu_frame(&self) -> Option { + let frames = self.frames.as_ref()?; + let device = device()?; + let mut pool = frames.pool.lock().unwrap(); + let index = pool.latest?; + let (width, height, ready_value) = (pool.width, pool.height, pool.latest_ready); + let fences = pool.fences.as_ref()?; + let (ready_fence_id, ready_fence) = (fences.ready_handle.id, fences.ready_handle.handle.0); + let (release_fence_id, release_fence) = (fences.release_handle.id, fences.release_handle.handle.0); + pool.next_release += 1; + let release_value = pool.next_release; + let frame = &mut pool.frames[index]; + let shared = frame.shared.as_ref()?; + let (texture_id, texture) = (shared.id, shared.handle.0); + frame.busy_until = frame.busy_until.max(release_value); + Some(NSCSharedFrame { + desc: Box::new(SharedFrameDesc { + size: std::mem::size_of::() as u32, + consumed: 0, + adapter_luid: device.luid, + texture_id, + texture, + ready_fence_id, + ready_fence, + ready_value, + release_fence_id, + release_fence, + release_value, + }), + frames: Arc::clone(frames), + width, + height, + released: false, + }) + } + + /// Passes the audio through until routed. Effects only apply to the sources set after them, so + /// this runs before any; optional, so a player whose effect cannot be activated just plays. + #[napi] + pub fn create_audio_tap(&self) -> Result { + let live = self.live.borrow(); + let player = &live.as_ref().ok_or_else(|| Error::from_reason("The player is closed"))?.player; + let tap = tap::Tap::new(); + let configuration = windows::Foundation::Collections::PropertySet::new().map_err(to_napi)?; + configuration + .Insert(&windows::core::HSTRING::from(tap::TAP_KEY), &windows::Foundation::PropertyValue::CreateUInt64(tap.id).map_err(to_napi)?) + .map_err(to_napi)?; + player + .AddAudioEffect(&windows::core::HSTRING::from(tap::TAP_CLASS), true, &configuration) + .map_err(to_napi)?; + let source = Box::new(tap::AudioTapSource::new(&tap)); + Ok(NSCAudioTap { tap, source }) + } + + #[napi(getter)] + pub fn frame_id(&self) -> f64 { + self.frames.as_ref().map_or(0., |frames| frames.generation.load(Ordering::Acquire) as f64) + } + + /// UI thread; replaces any previous one. + #[napi] + pub fn attach_surface_image_source(&self, key: String, width: u32, height: u32) -> bool { + let (Some(device), Some(raw)) = (device(), parse_pointer_key(&key)) else { return false }; + let attached = (|| -> windows::core::Result { + let unknown = unsafe { IUnknown::from_raw_borrowed(&raw) }.ok_or_else(windows::core::Error::empty)?; + let native: ISurfaceImageSourceNative = unknown.cast()?; + let dxgi: IDXGIDevice = device.device.cast()?; + unsafe { native.SetDevice(dxgi.as_raw()) }.ok()?; + Ok(XamlSurface { native, width, height }) + })(); + let attached = attached.ok(); + let ok = attached.is_some(); + *self.xaml.borrow_mut() = attached; + ok + } + + #[napi] + pub fn detach_surface_image_source(&self) { + self.xaml.borrow_mut().take(); + } + + /// UI thread. `false` without a surface of the frame's size. + #[napi] + pub fn present(&self) -> bool { + let (Some(frames), Some(device)) = (self.frames.as_ref(), device()) else { return false }; + let xaml = self.xaml.borrow(); + let Some(surface) = xaml.as_ref() else { return false }; + let pool = frames.pool.lock().unwrap(); + if pool.width != surface.width || pool.height != surface.height { + return false; + } + let Some(frame) = pool.latest() else { return false }; + surface.present(device, &frame.texture).is_ok() + } + + /// RGBA, top row first; `null` before the first frame. + #[napi] + pub fn read_pixels(&self) -> Option { + let (frames, device) = (self.frames.as_ref()?, device()?); + let mut staging = self.staging.borrow_mut(); + let pool = frames.pool.lock().unwrap(); + let frame = pool.latest()?; + let (width, height) = (pool.width, pool.height); + if staging.as_ref().is_none_or(|(_, w, h)| *w != width || *h != height) { + let desc = D3D11_TEXTURE2D_DESC { + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + ..texture_desc(width, height, false) + }; + let mut texture = None; + unsafe { device.device.CreateTexture2D(&desc, None, Some(&mut texture)) }.ok()?; + *staging = Some((texture?, width, height)); + } + let (texture, ..) = staging.as_ref()?; + unsafe { device.context.CopyResource(texture, &frame.texture) }; + drop(pool); + + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + unsafe { device.context.Map(texture, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) }.ok()?; + let row = width as usize * 4; + let mut rgba = vec![0u8; row * height as usize]; + for (y, dst) in rgba.chunks_exact_mut(row).enumerate() { + let src = unsafe { std::slice::from_raw_parts((mapped.pData as *const u8).add(y * mapped.RowPitch as usize), row) }; + for (dst, src) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { + dst.copy_from_slice(&[src[2], src[1], src[0], 255]); + } + } + unsafe { device.context.Unmap(texture, 0) }; + Some(Uint8Array::new(rgba)) + } +} + +/// A muted, autoplaying frame-server player for tests: Node has no WinRT projection to make one. +#[napi(js_name = "__createTestPlayer")] +pub fn create_test_player(env: Env, uri: String) -> Result { + ensure_cleanup_hook(&env)?; + let player = MediaPlayer::new().map_err(to_napi)?; + let setup = || -> windows::core::Result<()> { + player.SetIsVideoFrameServerEnabled(true)?; + player.SetIsMuted(true)?; + player.SetAutoPlay(true)?; + let uri = windows::Foundation::Uri::CreateUri(&windows::core::HSTRING::from(uri.as_str()))?; + player.SetSource(&windows::Media::Core::MediaSource::CreateFromUri(&uri)?) + }; + setup().map_err(to_napi)?; + let key = format!("0x{:x}", player.as_raw() as usize); + TEST_PLAYERS.with(|players| players.borrow_mut().push(ManuallyDrop::new(player))); + Ok(key) +} + +#[napi(js_name = "__closeTestPlayers")] +pub fn close_test_players() { + for player in TEST_PLAYERS.with(|players| std::mem::take(&mut *players.borrow_mut())) { + let player = ManuallyDrop::into_inner(player); + let _ = player.Close(); + } +} diff --git a/crates/canvas-napi/Cargo.toml b/crates/canvas-napi/Cargo.toml index f03aa667e..063cdd502 100644 --- a/crates/canvas-napi/Cargo.toml +++ b/crates/canvas-napi/Cargo.toml @@ -3,7 +3,7 @@ name = "canvas-napi" version = "0.1.0" edition = "2021" publish = false -description = "Node-API binding for the canvas plugins: provides the same global.CanvasModule the V8 bindings install, for Node-API hosts (NativeScript Windows, Node)." +description = "Node-API binding for the canvas plugins: provides the same global.CanvasModule the V8 bindings install, for Node-API hosts (NativeScript Windows and Linux, Node)." [lib] crate-type = ["cdylib"] @@ -31,5 +31,10 @@ canvas-core = { workspace = true, features = ["2d", "gl", "d3d"] } canvas-c = { workspace = true, features = ["2d", "webgl", "gl", "d3d"] } windows-core = "0.62" +# Linux shows frames in a GTK 4 (>= 4.6) widget, shared with GDK's GL context as EGLImages. +[target.'cfg(target_os = "linux")'.dependencies] +gtk = { package = "gtk4", version = "0.11", features = ["v4_6"] } +libloading = "0.8" + [build-dependencies] napi-build = "2.5.0" diff --git a/crates/canvas-napi/src/c2d/mod.rs b/crates/canvas-napi/src/c2d/mod.rs index c44a4cfc2..d19e5a0d6 100644 --- a/crates/canvas-napi/src/c2d/mod.rs +++ b/crates/canvas-napi/src/c2d/mod.rs @@ -54,6 +54,8 @@ impl ObjectFinalize for CanvasRenderingContext2D { unsafe fn render_2d(context: *mut c_void) { canvas_c::canvas_native_context_render(context as *mut CCanvasRenderingContext2D); + #[cfg(target_os = "linux")] + crate::host::linux::presented(context); #[cfg(target_os = "windows")] if canvas_c::canvas_native_context_is_lost(context as *const CCanvasRenderingContext2D) { crate::frame::report_lost(); @@ -118,47 +120,6 @@ impl CanvasRenderingContext2D { canvas_c::canvas_native_context_render(self.context); } - /// `drawImage(video, ...)` with a frame the video shares on the GPU (canvas-media's - /// `gpuFrame().address`), in video pixels. `false`: not drawn and not consumed (another - /// adapter, or no D3D context); draw it some other way. - #[cfg(target_os = "windows")] - #[napi(js_name = "__drawD3DSharedFrame")] - pub fn draw_d3d_shared_frame( - &self, - frame: f64, - width: i32, - height: i32, - sx: f64, - sy: f64, - sw: f64, - sh: f64, - dx: f64, - dy: f64, - dw: f64, - dh: f64, - ) -> bool { - let drawn = unsafe { - canvas_c::d3d::canvas_native_context_draw_d3d_shared_frame( - self.context, - frame as usize as *mut c_void, - width, - height, - sx as f32, - sy as f32, - sw as f32, - sh as f32, - dx as f32, - dy as f32, - dw as f32, - dh as f32, - ) - }; - if drawn { - self.dirty(); - } - drawn - } - #[napi] pub fn resize(&self, width: u32, height: u32) { let context = unsafe { &mut *self.context }; @@ -1499,3 +1460,47 @@ pub fn create_2d_context_with_pointer( canvas_c::canvas_native_context_reference(context); Some(CanvasRenderingContext2D::from_raw(context)) } + +#[cfg(target_os = "windows")] +#[napi] +impl CanvasRenderingContext2D { + /// `drawImage(video, ...)` with a frame the video shares on the GPU (canvas-media's + /// `gpuFrame().address`), in video pixels. `false`: not drawn and not consumed (another + /// adapter, or no D3D context); draw it some other way. + #[napi(js_name = "__drawD3DSharedFrame")] + pub fn draw_d3d_shared_frame( + &self, + frame: f64, + width: i32, + height: i32, + sx: f64, + sy: f64, + sw: f64, + sh: f64, + dx: f64, + dy: f64, + dw: f64, + dh: f64, + ) -> bool { + let drawn = unsafe { + canvas_c::d3d::canvas_native_context_draw_d3d_shared_frame( + self.context, + frame as usize as *mut c_void, + width, + height, + sx as f32, + sy as f32, + sw as f32, + sh as f32, + dx as f32, + dy as f32, + dw as f32, + dh as f32, + ) + }; + if drawn { + self.dirty(); + } + drawn + } +} diff --git a/crates/canvas-napi/src/gl/base.rs b/crates/canvas-napi/src/gl/base.rs index 60cf23adc..d759667d1 100644 --- a/crates/canvas-napi/src/gl/base.rs +++ b/crates/canvas-napi/src/gl/base.rs @@ -1169,6 +1169,35 @@ macro_rules! impl_webgl_context { } + /// Linux: `texImage2D/3D` (or `texSubImage*` with `sub`) from a canvas-media `NSCVideoFrame` + /// address, on the GPU when the frame is a GL texture. `false`: use the frame's pixels. + #[napi(js_name = "__texImageVideoFrame")] + pub fn tex_image_video_frame( + &self, + frame: f64, + target: u32, + level: i32, + internalformat: i32, + three_d: bool, + sub: bool, + xoffset: i32, + yoffset: i32, + zoffset: i32, + ) -> bool { + #[cfg(target_os = "linux")] + return unsafe { + canvas_c::linux_video_frame::canvas_native_webgl_tex_image_video_frame( + frame as usize as *mut std::ffi::c_void, + target, level, internalformat, three_d, sub, xoffset, yoffset, zoffset, self.state, + ) + }; + #[cfg(not(target_os = "linux"))] + { + let _ = (frame, target, level, internalformat, three_d, sub, xoffset, yoffset, zoffset); + false + } + } + #[napi] pub fn tex_parameterf(&self, target: u32, pname: u32, param: f64) { canvas_c::canvas_native_webgl_tex_parameterf(target, pname, param as f32, self.state); diff --git a/crates/canvas-napi/src/gl/extensions.rs b/crates/canvas-napi/src/gl/extensions.rs index 094ca7a6a..e5924065e 100644 --- a/crates/canvas-napi/src/gl/extensions.rs +++ b/crates/canvas-napi/src/gl/extensions.rs @@ -385,10 +385,11 @@ impl OES_vertex_array_object { canvas_c::canvas_native_webgl_oes_vertex_array_object_is_vertex_array_oes(array_object, self.0) } + /// `null` unbinds. #[napi(js_name = "bindVertexArrayOES")] - pub fn bind_vertex_array_oes(&self, array_object: u32) { + pub fn bind_vertex_array_oes(&self, array_object: Option) { canvas_c::canvas_native_webgl_oes_vertex_array_object_bind_vertex_array_oes( - array_object, + array_object.unwrap_or(0), self.0, ) } diff --git a/crates/canvas-napi/src/gl/mod.rs b/crates/canvas-napi/src/gl/mod.rs index 60cd05aa9..053b9f195 100644 --- a/crates/canvas-napi/src/gl/mod.rs +++ b/crates/canvas-napi/src/gl/mod.rs @@ -119,6 +119,8 @@ pub(crate) unsafe fn present_webgl(state: *mut std::ffi::c_void) { { crate::frame::report_lost(); } + #[cfg(target_os = "linux")] + crate::host::linux::presented(state as *mut std::ffi::c_void); } impl web_g_l_rendering_context { diff --git a/crates/canvas-napi/src/gl2/mod.rs b/crates/canvas-napi/src/gl2/mod.rs index 371fe358f..6632c4522 100644 --- a/crates/canvas-napi/src/gl2/mod.rs +++ b/crates/canvas-napi/src/gl2/mod.rs @@ -175,15 +175,9 @@ impl web_g_l_2_rendering_context { } #[napi] - pub fn clear_bufferfi( - &self, - buffer: ClassInstance, - drawbuffer: i32, - depth: f64, - stencil: i32, - ) { + pub fn clear_bufferfi(&self, buffer: u32, drawbuffer: i32, depth: f64, stencil: i32) { canvas_c::canvas_native_webgl2_clear_bufferfi( - buffer.0, + buffer, drawbuffer, depth as f32, stencil, @@ -194,7 +188,7 @@ impl web_g_l_2_rendering_context { #[napi] pub fn clear_bufferfv( &self, - buffer: ClassInstance, + buffer: u32, drawbuffer: i32, values: Either, Float32Array>, ) { @@ -202,7 +196,7 @@ impl web_g_l_2_rendering_context { Either::A(array) => { let array = array.into_iter().map(|v| v as f32).collect::>(); canvas_c::canvas_native_webgl2_clear_bufferfv( - buffer.0, + buffer, drawbuffer, array.as_ptr(), array.len(), @@ -210,7 +204,7 @@ impl web_g_l_2_rendering_context { ) } Either::B(value) => canvas_c::canvas_native_webgl2_clear_bufferfv( - buffer.0, + buffer, drawbuffer, value.as_ptr(), value.len(), @@ -220,22 +214,17 @@ impl web_g_l_2_rendering_context { } #[napi] - pub fn clear_bufferiv( - &self, - buffer: ClassInstance, - drawbuffer: i32, - values: Either, Int32Array>, - ) { + pub fn clear_bufferiv(&self, buffer: u32, drawbuffer: i32, values: Either, Int32Array>) { match values { Either::A(array) => canvas_c::canvas_native_webgl2_clear_bufferiv( - buffer.0, + buffer, drawbuffer, array.as_ptr(), array.len(), self.state, ), Either::B(value) => canvas_c::canvas_native_webgl2_clear_bufferiv( - buffer.0, + buffer, drawbuffer, value.as_ptr(), value.len(), @@ -247,20 +236,20 @@ impl web_g_l_2_rendering_context { #[napi] pub fn clear_bufferuiv( &self, - buffer: ClassInstance, + buffer: u32, drawbuffer: i32, values: Either, Uint32Array>, ) { match values { Either::A(array) => canvas_c::canvas_native_webgl2_clear_bufferuiv( - buffer.0, + buffer, drawbuffer, array.as_ptr(), array.len(), self.state, ), Either::B(value) => canvas_c::canvas_native_webgl2_clear_bufferuiv( - buffer.0, + buffer, drawbuffer, value.as_ptr(), value.len(), diff --git a/crates/canvas-napi/src/gpu/device.rs b/crates/canvas-napi/src/gpu/device.rs index e78a0dd68..e5d89be16 100644 --- a/crates/canvas-napi/src/gpu/device.rs +++ b/crates/canvas-napi/src/gpu/device.rs @@ -840,12 +840,6 @@ impl g_p_u_device { Ok(unsafe { g_p_u_texture::from_raw(texture) }) } - #[cfg(target_os = "windows")] - #[napi(js_name = "__getAdapterLuid")] - pub fn get_adapter_luid(&self) -> f64 { - unsafe { canvas_c::webgpu::gpu_shared_frame::canvas_native_webgpu_device_get_adapter_luid(self.ptr()) as f64 } - } - /// `importExternalTexture({ nativeTexture, width, height, label? })`: packages/canvas resolves /// the video to a platform texture; undefined where the backend cannot import it. #[napi( @@ -871,3 +865,27 @@ impl g_p_u_device { }) } } + +/// Linux: 53 bits of the device UUID, which canvas-media matches with its GL device's. +#[cfg(target_os = "linux")] +#[napi] +impl g_p_u_device { + #[napi(js_name = "__getAdapterLuid")] + pub fn get_adapter_luid(&self) -> f64 { + unsafe { + canvas_c::webgpu::linux_video_frame::canvas_native_webgpu_device_get_uuid_key(self.ptr()) as f64 + } + } +} + +#[cfg(target_os = "windows")] +#[napi] +impl g_p_u_device { + #[napi(js_name = "__getAdapterLuid")] + pub fn get_adapter_luid(&self) -> f64 { + unsafe { + canvas_c::webgpu::gpu_shared_frame::canvas_native_webgpu_device_get_adapter_luid(self.ptr()) + as f64 + } + } +} diff --git a/crates/canvas-napi/src/host/linux.rs b/crates/canvas-napi/src/host/linux.rs new file mode 100644 index 000000000..7bf68d808 --- /dev/null +++ b/crates/canvas-napi/src/host/linux.rs @@ -0,0 +1,1613 @@ +//! `CanvasModule.NSCCanvas` on Linux: the native side of a canvas view, a GTK 4 (>= 4.6) widget. +//! +//! Contexts are GLES on the system EGL (canvas-core's `egl`), rendering into pbuffers on GDK's +//! EGL display. At frame end the pbuffer is blitted into a texture GDK sees through an EGLImage +//! and shown as a `GdkGLTexture`, so frames never leave the GPU. When GDK is not on EGL (GLX) or +//! lacks EGLImage support, frames are read back into memory textures instead. +//! +//! The drawing buffer is sized in physical pixels (300x150 until set, as on the web); the widget +//! places it with the `fit` mode (`canvas_core::fit`) at its scale factor. + +use std::cell::{Cell, OnceCell, RefCell}; +use std::collections::HashMap; +use std::ffi::c_void; +use std::rc::{Rc, Weak}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use canvas_c::{CanvasRenderingContext2D as CCanvasRenderingContext2D, WebGLState}; +use canvas_core::fit::{surface_transform, CanvasFit}; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{gdk, glib, graphene}; +use napi::bindgen_prelude::ObjectFinalize; +use napi::{Env, Error, Result}; +use napi_derive::napi; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct CanvasView { + pub frame: RefCell>, + /// GL frames are bottom-up. + pub flipped: Cell, + pub fit: Cell, + /// The drawing buffer size its owner last set. + pub size: Cell<(i32, i32)>, + /// Buffer pixels per DIP, as the TS view reports it. + pub density: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CanvasView { + const NAME: &'static str = "NSCCanvasView"; + type Type = super::CanvasView; + type ParentType = gtk::Widget; + } + + impl ObjectImpl for CanvasView {} + + impl WidgetImpl for CanvasView { + fn measure(&self, _: gtk::Orientation, _: i32) -> (i32, i32, i32, i32) { + (0, 0, -1, -1) + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let Some(frame) = self.frame.borrow().clone() else { + return; + }; + let widget = self.obj(); + let (width, height) = ( + WidgetExt::width(&*widget) as f32, + WidgetExt::height(&*widget) as f32, + ); + let scale = self.density.get(); + let (frame_width, frame_height) = ( + TextureExt::width(&frame) as f32, + TextureExt::height(&frame) as f32, + ); + let t = surface_transform( + self.fit.get(), + (frame_width, frame_height), + (scale, scale), + (width, height), + ); + let rect = graphene::Rect::new( + t.offset_x, + t.offset_y, + frame_width * t.scale_x, + frame_height * t.scale_y, + ); + snapshot.push_clip(&graphene::Rect::new(0., 0., width, height)); + if self.flipped.get() { + snapshot.save(); + snapshot.translate(&graphene::Point::new(0., 2. * rect.y() + rect.height())); + snapshot.scale(1., -1.); + snapshot.append_texture(&frame, &rect); + snapshot.restore(); + } else { + snapshot.append_texture(&frame, &rect); + } + snapshot.pop(); + } + } +} + +glib::wrapper! { + pub struct CanvasView(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Buildable, gtk::ConstraintTarget; +} + +impl CanvasView { + fn show(&self, frame: gdk::Texture, flipped: bool) { + let imp = self.imp(); + imp.flipped.set(flipped); + imp.frame.replace(Some(frame)); + self.queue_draw(); + } + + fn set_fit(&self, fit: CanvasFit) { + self.imp().fit.set(fit); + self.queue_draw(); + } + + fn set_size(&self, width: u32, height: u32) { + self.imp().size.set((width as i32, height as i32)); + } + + fn set_density(&self, density: f32) { + self.imp().density.set(density); + self.queue_draw(); + } +} + +const EGL_GL_TEXTURE_2D_KHR: u32 = 0x30B1; +const EGL_GL_TEXTURE_LEVEL_KHR: i32 = 0x30BC; +const EGL_IMAGE_PRESERVED_KHR: i32 = 0x30D2; +const EGL_NONE: i32 = 0x3038; +const EGL_EXTENSIONS: i32 = 0x3055; + +type CreateImage = + unsafe extern "C" fn(*mut c_void, *mut c_void, u32, *mut c_void, *const i32) -> *mut c_void; +type DestroyImage = unsafe extern "C" fn(*mut c_void, *mut c_void) -> u32; +type ImageTargetTexture = unsafe extern "C" fn(u32, *mut c_void); +type QueryString = unsafe extern "C" fn(*mut c_void, i32) -> *const std::ffi::c_char; + +type CreateSync = unsafe extern "C" fn(*mut c_void, u32, *const i32) -> *mut c_void; +type ClientWaitSync = unsafe extern "C" fn(*mut c_void, *mut c_void, i32, u64) -> i32; +type DestroySync = unsafe extern "C" fn(*mut c_void, *mut c_void) -> u32; + +const EGL_SYNC_FENCE_KHR: u32 = 0x30F9; +const EGL_SYNC_FLUSH_COMMANDS_BIT_KHR: i32 = 0x0001; +const EGL_FOREVER_KHR: u64 = u64::MAX; + +/// EGLImage and fence entry points, when canvases and GDK share an EGL display. +#[derive(Clone, Copy)] +struct Images { + display: usize, + create: CreateImage, + destroy: DestroyImage, + target_texture: ImageTargetTexture, + fence: Option<(CreateSync, ClientWaitSync, DestroySync)>, +} + +impl Images { + fn display(&self) -> *mut c_void { + self.display as *mut c_void + } + + /// Blocks this thread until the GPU finished what the current context submitted, so another + /// context (GDK's, on the UI thread) sees it complete. + unsafe fn finish(&self) { + match self.fence { + Some((create, wait, destroy)) => unsafe { + let sync = create(self.display(), EGL_SYNC_FENCE_KHR, std::ptr::null()); + if sync.is_null() { + gl_bindings::Finish(); + return; + } + wait( + self.display(), + sync, + EGL_SYNC_FLUSH_COMMANDS_BIT_KHR, + EGL_FOREVER_KHR, + ); + destroy(self.display(), sync); + }, + None => unsafe { gl_bindings::Finish() }, + } + } +} + +/// GDK's side: a GL context of its own (whose textures GSK's renderer shares) and, with a shared +/// display, the EGLImage entry points. +struct Gdk { + context: Option, + images: Option, +} + +thread_local! { + static GDK: OnceCell = const { OnceCell::new() }; + /// Presenters by the canvas-c context they show. + static PRESENTERS: RefCell>> = RefCell::new(HashMap::new()); +} + +/// GTK-rs wants to have initialized GTK itself; the app already has. +fn gtk_ready() -> bool { + if !gtk::is_initialized() && unsafe { gtk::ffi::gtk_is_initialized() } != 0 { + unsafe { gtk::set_initialized() }; + } + gtk::is_initialized_main_thread() +} + +fn proc_address(name: &str) -> Option { + let address = canvas_core::gpu::gl::get_proc_address(name); + (!address.is_null()).then(|| unsafe { std::mem::transmute_copy::<*const c_void, T>(&address) }) +} + +/// `eglGetCurrentDisplay` without bringing canvas EGL up (it must start on GDK's display). +fn current_egl_display() -> *mut c_void { + static LIB: std::sync::OnceLock> = std::sync::OnceLock::new(); + let Some(lib) = LIB.get_or_init(|| unsafe { libloading::Library::new("libEGL.so.1") }.ok()) + else { + return std::ptr::null_mut(); + }; + unsafe { + lib + .get:: *mut c_void>(b"eglGetCurrentDisplay\0") + .map_or(std::ptr::null_mut(), |f| f()) + } +} + +fn clear_gdk_current() { + unsafe { gdk::ffi::gdk_gl_context_clear_current() }; +} + +fn init_gdk() -> Gdk { + // GDK caches its current context: it must forget it whenever a canvas binds its own. + canvas_core::gpu::gl::set_before_bind(clear_gdk_current); + let context = gdk::Display::default().and_then(|display| { + let context = display.create_gl_context().ok()?; + context.realize().ok()?; + Some(context) + }); + let Some(context) = context else { + log::warn!("canvas: GDK has no GL context; canvases are read back into memory textures"); + return Gdk { + context: None, + images: None, + }; + }; + context.make_current(); + let display = current_egl_display(); + clear_gdk_current(); + let images = (!display.is_null() && canvas_core::gpu::gl::set_host_display(display)) + .then(|| load_images(display)) + .flatten(); + if images.is_none() { + log::warn!( + "canvas: GDK's EGL display is not shareable; canvases are read back into memory textures" + ); + } + Gdk { + context: Some(context), + images, + } +} + +fn load_images(display: *mut c_void) -> Option { + let query: QueryString = proc_address("eglQueryString")?; + let extensions = unsafe { query(display, EGL_EXTENSIONS) }; + if extensions.is_null() { + return None; + } + let extensions = unsafe { std::ffi::CStr::from_ptr(extensions) }.to_string_lossy(); + let has = |name: &str| extensions.split(' ').any(|e| e == name); + if !has("EGL_KHR_image_base") || !has("EGL_KHR_gl_texture_2D_image") { + return None; + } + let fence = has("EGL_KHR_fence_sync") + .then(|| { + Some(( + proc_address("eglCreateSyncKHR")?, + proc_address("eglClientWaitSyncKHR")?, + proc_address("eglDestroySyncKHR")?, + )) + }) + .flatten(); + Some(Images { + display: display as usize, + create: proc_address("eglCreateImageKHR")?, + destroy: proc_address("eglDestroyImageKHR")?, + target_texture: proc_address("glEGLImageTargetTexture2DOES")?, + fence, + }) +} + +fn with_gdk(f: impl FnOnce(&Gdk) -> R) -> R { + GDK.with(|gdk| f(gdk.get_or_init(init_gdk))) +} + +/// Brings canvas EGL up on GDK's display before anything else makes a context. +pub fn init() { + if gtk_ready() { + with_gdk(|_| ()); + } +} + +/// A texture shared with GDK, and the framebuffer that fills it. +struct Slot { + texture: u32, + framebuffer: u32, + image: *mut c_void, + gdk_texture: u32, + width: i32, + height: i32, + format: u32, + /// GDK still holds a `GdkGLTexture` of it. + busy: Rc>, + /// The order it was last shown in. + shown: u64, +} + +/// Shows a context's frames in its view. +struct Presenter { + view: glib::WeakRef, + egl_context: *mut c_void, + flipped: bool, + slots: RefCell>, + retired: RefCell>, + frames: Cell, +} + +const MAX_SLOTS: usize = 4; + +/// Restores the GL bindings a present touches, so Skia's and WebGL's tracked state stays true. +struct SavedBindings { + read: i32, + draw: i32, + texture: i32, + scissor: bool, +} + +impl SavedBindings { + unsafe fn save() -> Self { + let mut saved = SavedBindings { + read: 0, + draw: 0, + texture: 0, + scissor: false, + }; + unsafe { + gl_bindings::GetIntegerv(gl_bindings::READ_FRAMEBUFFER_BINDING, &mut saved.read); + gl_bindings::GetIntegerv(gl_bindings::DRAW_FRAMEBUFFER_BINDING, &mut saved.draw); + gl_bindings::GetIntegerv(gl_bindings::TEXTURE_BINDING_2D, &mut saved.texture); + saved.scissor = gl_bindings::IsEnabled(gl_bindings::SCISSOR_TEST) != 0; + } + saved + } + + unsafe fn restore(&self) { + unsafe { + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, self.read as u32); + gl_bindings::BindFramebuffer(gl_bindings::DRAW_FRAMEBUFFER, self.draw as u32); + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, self.texture as u32); + if self.scissor { + gl_bindings::Enable(gl_bindings::SCISSOR_TEST); + } + } + } +} + +fn current_egl_context() -> *mut c_void { + type GetCurrentContext = unsafe extern "C" fn() -> *mut c_void; + thread_local! { + static GET: Option = proc_address("eglGetCurrentContext"); + } + GET.with(|get| get.map_or(std::ptr::null_mut(), |f| unsafe { f() })) +} + +impl Presenter { + fn new(view: &CanvasView, egl_context: *mut c_void, flipped: bool) -> Rc { + Rc::new(Presenter { + view: view.downgrade(), + egl_context, + flipped, + slots: RefCell::new(Vec::new()), + retired: RefCell::new(Vec::new()), + frames: Cell::new(0), + }) + } + + /// Called right after the context rendered a frame, with it current. + fn present(&self, width: i32, height: i32) { + let Some(view) = self.view.upgrade() else { + return; + }; + if self.egl_context.is_null() + || current_egl_context() != self.egl_context + || width <= 0 + || height <= 0 + { + return; + } + let frame = with_gdk(|gdk| match (gdk.context.as_ref(), gdk.images.as_ref()) { + (Some(context), Some(images)) => self.share(context, images, width, height), + _ => None, + }); + let frame = frame.or_else(|| unsafe { read_back(width, height) }); + if let Some(frame) = frame { + view.show(frame, self.flipped); + } + } + + /// Blits the frame into a free shared texture and wraps it for GDK. + fn share( + &self, + context: &gdk::GLContext, + images: &Images, + width: i32, + height: i32, + ) -> Option { + let saved = unsafe { SavedBindings::save() }; + let format = unsafe { frame_format() }; + let mut slots = self.slots.borrow_mut(); + let mut retired = self.retired.borrow_mut(); + let (stale, kept): (Vec, Vec) = slots + .drain(..) + .partition(|s| (s.width, s.height, s.format) != (width, height, format)); + *slots = kept; + retired.extend(stale); + + let mut released = Vec::new(); + retired.retain(|slot| { + if slot.busy.get() { + return true; + } + unsafe { + gl_bindings::DeleteFramebuffers(1, &slot.framebuffer); + gl_bindings::DeleteTextures(1, &slot.texture); + (images.destroy)(images.display(), slot.image); + } + released.push(slot.gdk_texture); + false + }); + + let index = match slots.iter().position(|s| !s.busy.get()) { + Some(index) => index, + None if slots.len() < MAX_SLOTS => { + match unsafe { create_slot(images, self.egl_context, width, height, format) } { + Some(slot) => { + slots.push(slot); + slots.len() - 1 + } + None => { + unsafe { saved.restore() }; + return None; + } + } + } + // GDK holds them all: reuse the oldest. + None => (0..slots.len()).min_by_key(|&i| slots[i].shown)?, + }; + unsafe { + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, 0); + gl_bindings::BindFramebuffer(gl_bindings::DRAW_FRAMEBUFFER, slots[index].framebuffer); + gl_bindings::Disable(gl_bindings::SCISSOR_TEST); + gl_bindings::BlitFramebuffer( + 0, + 0, + width, + height, + 0, + 0, + width, + height, + gl_bindings::COLOR_BUFFER_BIT, + gl_bindings::NEAREST, + ); + saved.restore(); + gl_bindings::Flush(); + } + + context.make_current(); + unsafe { + for id in released.into_iter().filter(|id| *id != 0) { + gl_bindings::DeleteTextures(1, &id); + } + } + let slot = &mut slots[index]; + if slot.gdk_texture == 0 { + slot.gdk_texture = unsafe { import_image(images, slot.image) }?; + } + let frames = self.frames.get() + 1; + self.frames.set(frames); + slot.shown = frames; + slot.busy.set(true); + let busy = slot.busy.clone(); + let texture = unsafe { + gdk::GLTexture::with_release_func(context, slot.gdk_texture, width, height, move || { + busy.set(false) + }) + }; + Some(texture.upcast()) + } +} + +/// The sized format a frame texture needs: a multisampled default framebuffer only blits into the +/// same format (RGB8 without alpha). Leaves the default framebuffer bound for reading. +unsafe fn frame_format() -> u32 { + let mut alpha = 0; + unsafe { + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, 0); + gl_bindings::GetFramebufferAttachmentParameteriv( + gl_bindings::READ_FRAMEBUFFER, + gl_bindings::BACK, + gl_bindings::FRAMEBUFFER_ATTACHMENT_ALPHA_SIZE, + &mut alpha, + ); + } + if alpha > 0 { + gl_bindings::RGBA8 + } else { + gl_bindings::RGB8 + } +} + +/// A texture of the frame's format in the canvas context, its framebuffer and its EGLImage. +unsafe fn create_slot( + images: &Images, + egl_context: *mut c_void, + width: i32, + height: i32, + format: u32, +) -> Option { + unsafe { + let mut texture = 0; + gl_bindings::GenTextures(1, &mut texture); + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, texture); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MIN_FILTER, + gl_bindings::LINEAR as i32, + ); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MAG_FILTER, + gl_bindings::LINEAR as i32, + ); + gl_bindings::TexStorage2D(gl_bindings::TEXTURE_2D, 1, format, width, height); + let mut framebuffer = 0; + gl_bindings::GenFramebuffers(1, &mut framebuffer); + gl_bindings::BindFramebuffer(gl_bindings::DRAW_FRAMEBUFFER, framebuffer); + gl_bindings::FramebufferTexture2D( + gl_bindings::DRAW_FRAMEBUFFER, + gl_bindings::COLOR_ATTACHMENT0, + gl_bindings::TEXTURE_2D, + texture, + 0, + ); + let complete = gl_bindings::CheckFramebufferStatus(gl_bindings::DRAW_FRAMEBUFFER) + == gl_bindings::FRAMEBUFFER_COMPLETE; + let attributes = [ + EGL_GL_TEXTURE_LEVEL_KHR, + 0, + EGL_IMAGE_PRESERVED_KHR, + 1, + EGL_NONE, + ]; + let image = if complete { + (images.create)( + images.display(), + egl_context, + EGL_GL_TEXTURE_2D_KHR, + texture as usize as *mut c_void, + attributes.as_ptr(), + ) + } else { + std::ptr::null_mut() + }; + if image.is_null() { + log::error!("canvas: could not share a {width}x{height} frame texture with GDK"); + gl_bindings::DeleteFramebuffers(1, &framebuffer); + gl_bindings::DeleteTextures(1, &texture); + return None; + } + Some(Slot { + texture, + framebuffer, + image, + gdk_texture: 0, + width, + height, + format, + busy: Rc::new(Cell::new(false)), + shown: 0, + }) + } +} + +/// The EGLImage as a texture of GDK's (current) context. +unsafe fn import_image(images: &Images, image: *mut c_void) -> Option { + unsafe { + while gl_bindings::GetError() != gl_bindings::NO_ERROR {} + let mut texture = 0; + gl_bindings::GenTextures(1, &mut texture); + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, texture); + (images.target_texture)(gl_bindings::TEXTURE_2D, image); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MIN_FILTER, + gl_bindings::LINEAR as i32, + ); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MAG_FILTER, + gl_bindings::LINEAR as i32, + ); + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, 0); + if gl_bindings::GetError() != gl_bindings::NO_ERROR { + gl_bindings::DeleteTextures(1, &texture); + log::error!("canvas: GDK's GL context cannot use EGLImages"); + return None; + } + Some(texture) + } +} + +/// The current context's default framebuffer, copied into `pixels` (RGBA8, bottom-up). +unsafe fn read_pixels(width: i32, height: i32, pixels: &mut [u8]) { + unsafe { + let (mut read, mut pack_buffer, mut alignment, mut row_length) = (0, 0, 0, 0); + gl_bindings::GetIntegerv(gl_bindings::READ_FRAMEBUFFER_BINDING, &mut read); + gl_bindings::GetIntegerv(gl_bindings::PIXEL_PACK_BUFFER_BINDING, &mut pack_buffer); + gl_bindings::GetIntegerv(gl_bindings::PACK_ALIGNMENT, &mut alignment); + gl_bindings::GetIntegerv(gl_bindings::PACK_ROW_LENGTH, &mut row_length); + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, 0); + gl_bindings::BindBuffer(gl_bindings::PIXEL_PACK_BUFFER, 0); + gl_bindings::PixelStorei(gl_bindings::PACK_ALIGNMENT, 4); + gl_bindings::PixelStorei(gl_bindings::PACK_ROW_LENGTH, 0); + gl_bindings::ReadPixels( + 0, + 0, + width, + height, + gl_bindings::RGBA, + gl_bindings::UNSIGNED_BYTE, + pixels.as_mut_ptr() as *mut c_void, + ); + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, read as u32); + gl_bindings::BindBuffer(gl_bindings::PIXEL_PACK_BUFFER, pack_buffer as u32); + gl_bindings::PixelStorei(gl_bindings::PACK_ALIGNMENT, alignment); + gl_bindings::PixelStorei(gl_bindings::PACK_ROW_LENGTH, row_length); + } +} + +/// The current context's default framebuffer as a memory texture. +unsafe fn read_back(width: i32, height: i32) -> Option { + let mut pixels = vec![0u8; width as usize * height as usize * 4]; + unsafe { read_pixels(width, height, &mut pixels) }; + let bytes = glib::Bytes::from_owned(pixels); + Some( + gdk::MemoryTexture::new( + width, + height, + gdk::MemoryFormat::R8g8b8a8Premultiplied, + &bytes, + width as usize * 4, + ) + .upcast(), + ) +} + +/// Frame end for a context (`render_2d` / `present_webgl`): shows it if a view owns it. +pub(crate) fn presented(context: *mut c_void) { + let Some(presenter) = + PRESENTERS.with(|p| p.borrow().get(&(context as usize)).and_then(Weak::upgrade)) + else { + return; + }; + let Some((width, height)) = presenter.view.upgrade().map(|view| view.imp().size.get()) else { + return; + }; + presenter.present(width, height); +} + +/// The context the view owns; a view gets at most one, as on the web. +enum Context { + None, + TwoD(*mut CCanvasRenderingContext2D), + WebGL(*mut WebGLState), + WebGPU(*const canvas_c::webgpu::gpu_canvas_context::CanvasGPUCanvasContext), +} + +impl Context { + fn pointer(&self) -> usize { + match *self { + Context::None => 0, + Context::TwoD(context) => context as usize, + Context::WebGL(state) => state as usize, + Context::WebGPU(context) => context as usize, + } + } +} + +#[napi(js_name = "NSCCanvas", custom_finalize)] +pub struct NSCCanvas { + view: CanvasView, + surface_width: u32, + surface_height: u32, + /// The view's scale factor: physical pixels per DIP. + scale_x: f32, + scale_y: f32, + /// The view's size in DIPs, 0 until laid out. + view_width: f32, + view_height: f32, + fit: CanvasFit, + context: Context, + presenter: Option>, + /// Set once transferred to an OffscreenCanvas (`transferToOffscreenSurface`). + offscreen: *const canvas_c::offscreen::CanvasOffscreenSurface, + offscreen_view: u64, +} + +type OffscreenListener<'a> = napi::bindgen_prelude::Function< + 'a, + napi::bindgen_prelude::FnArgs<(u32, u32, u32)>, + napi::threadsafe_function::UnknownReturnValue, +>; + +struct OffscreenSink(Box); + +extern "C" fn offscreen_event( + data: *mut c_void, + event: canvas_c::offscreen::CanvasOffscreenEvent, + a: u32, + b: u32, +) { + let sink = unsafe { &*(data as *const OffscreenSink) }; + (sink.0)(event as u32, a, b); +} + +extern "C" fn offscreen_release(data: *mut c_void) { + drop(unsafe { Box::from_raw(data as *mut OffscreenSink) }); +} + +impl ObjectFinalize for NSCCanvas { + fn finalize(mut self, _: Env) -> Result<()> { + self.detach_offscreen_surface(); + let key = self.context.pointer(); + PRESENTERS.with(|p| p.borrow_mut().remove(&key)); + match self.context { + Context::None => {} + Context::TwoD(context) => canvas_c::canvas_native_context_release(context), + Context::WebGL(state) => canvas_c::canvas_native_webgl_state_destroy(state), + Context::WebGPU(context) => unsafe { + canvas_c::webgpu::linux_frames::canvas_native_webgpu_context_set_linux_sink(context, None); + canvas_c::webgpu::gpu_canvas_context::canvas_native_webgpu_context_release(context) + }, + } + if self.offscreen_view != 0 { + drop_offscreen_view(self.offscreen_view); + } + Ok(()) + } +} + +impl NSCCanvas { + fn density(&self) -> f32 { + self.scale_x.max(1.) + } + + fn attach(&mut self, context: Context, egl_context: *mut c_void, flipped: bool) { + let key = context.pointer(); + self.context = context; + let presenter = Presenter::new(&self.view, egl_context, flipped); + PRESENTERS.with(|p| p.borrow_mut().insert(key, Rc::downgrade(&presenter))); + self.presenter = Some(presenter); + } +} + +#[napi] +impl NSCCanvas { + /// Makes the view (a GtkWidget, see `widget`). Needs GTK initialized on this thread. + #[napi(constructor)] + pub fn new() -> Result { + if !gtk_ready() { + return Err(Error::from_reason( + "NSCCanvas needs GTK 4 initialized on this thread", + )); + } + with_gdk(|_| ()); + let view: CanvasView = glib::Object::new(); + view.set_size(300, 150); + view.set_density(1.); + Ok(Self { + view, + surface_width: 300, + surface_height: 150, + scale_x: 1., + scale_y: 1., + view_width: 0., + view_height: 0., + fit: CanvasFit::default(), + context: Context::None, + presenter: None, + offscreen: std::ptr::null(), + offscreen_view: 0, + }) + } + + /// The GtkWidget's address, for `new interop.Pointer(widget)`. + #[napi(getter)] + pub fn widget(&self) -> f64 { + self.view.as_ptr() as usize as f64 + } + + #[napi(getter)] + pub fn surface_width(&self) -> u32 { + if !self.offscreen.is_null() { + return unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_get_width(self.offscreen) + }; + } + self.surface_width + } + + #[napi(setter)] + pub fn set_surface_width(&mut self, width: f64) { + self.set_surface_size(width, self.surface_height as f64); + } + + #[napi(getter)] + pub fn surface_height(&self) -> u32 { + if !self.offscreen.is_null() { + return unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_get_height(self.offscreen) + }; + } + self.surface_height + } + + #[napi(setter)] + pub fn set_surface_height(&mut self, height: f64) { + self.set_surface_size(self.surface_width as f64, height); + } + + /// `CanvasFit` as an int (0 none, 1 fill, 2 fitX, 3 fitY, 4 scaleDown), as on iOS. + #[napi(getter)] + pub fn fit(&self) -> i32 { + self.fit as i32 + } + + #[napi(setter)] + pub fn set_fit(&mut self, fit: i32) { + if let Some(fit) = CanvasFit::from_i32(fit) { + self.fit = fit; + self.view.set_fit(fit); + } + } + + #[napi(getter)] + pub fn drawing_buffer_width(&self) -> u32 { + self.surface_width() + } + + #[napi(getter)] + pub fn drawing_buffer_height(&self) -> u32 { + self.surface_height() + } + + /// Hands the view to an OffscreenCanvas: whatever thread renders its context shows frames + /// here. `listener(event, a, b)` gets `CanvasOffscreenEvent`s on this thread. + #[napi( + js_name = "transferToOffscreenSurface", + ts_args_type = "listener: (event: number, a: number, b: number) => void" + )] + pub fn transfer_to_offscreen_surface( + &mut self, + listener: OffscreenListener, + ) -> Result> { + if !matches!(self.context, Context::None) || !self.offscreen.is_null() { + return Ok(None); + } + let (id, surface) = create_offscreen_surface( + &self.view, + self.surface_width, + self.surface_height, + self.density(), + ); + if surface.is_null() { + drop_offscreen_view(id); + return Ok(None); + } + // Weak: a view's events don't keep the env alive. + let tsfn = listener + .build_threadsafe_function::<(u32, u32, u32)>() + .weak::() + .build_callback(|ctx| Ok(napi::bindgen_prelude::FnArgs::from(ctx.value)))?; + let sink = Box::new(OffscreenSink(Box::new(move |event, a, b| { + tsfn.call( + (event, a, b), + napi::threadsafe_function::ThreadsafeFunctionCallMode::NonBlocking, + ); + }))); + unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_set_ui_sink( + surface, + canvas_c::offscreen::CanvasOffscreenUiSink { + data: Box::into_raw(sink) as *mut c_void, + call: Some(offscreen_event), + release: Some(offscreen_release), + }, + ); + canvas_c::offscreen::canvas_native_offscreen_surface_reference(surface); + } + self.offscreen = surface; + self.offscreen_view = id; + Ok(crate::offscreen::OffscreenSurface::from_raw(surface)) + } + + /// `quality` is 0..1. + #[napi(js_name = "offscreenToDataURL")] + pub fn offscreen_to_data_url(&self, format: String, quality: f64) -> Option { + if self.offscreen.is_null() { + return None; + } + let format = std::ffi::CString::new(format).ok()?; + let url = unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_to_data_url( + self.offscreen, + format.as_ptr(), + (quality * 100.) as u32, + ) + }; + if url.is_null() { + return None; + } + let string = unsafe { std::ffi::CStr::from_ptr(url) } + .to_string_lossy() + .into_owned(); + canvas_c::canvas_native_string_destroy(url); + Some(string) + } + + #[napi] + pub fn detach_offscreen_surface(&mut self) { + let surface = std::mem::replace(&mut self.offscreen, std::ptr::null()); + if !surface.is_null() { + unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_detach_view(surface); + canvas_c::offscreen::canvas_native_offscreen_surface_release(surface); + } + drop_offscreen_view(self.offscreen_view); + } + } + + /// The context's pointer as a decimal string (0 without one), as the iOS view's `nativeContext`. + #[napi(getter)] + pub fn native_context(&self) -> String { + self.context.pointer().to_string() + } + + /// The drawing buffer size in physical pixels; resizes (and clears) an existing context. + #[napi] + pub fn set_surface_size(&mut self, width: f64, height: f64) { + // `max` also maps NaN to 1. + let (width, height) = (width.max(1.) as u32, height.max(1.) as u32); + // A transferred canvas's bitmap is its OffscreenCanvas's. + if !self.offscreen.is_null() || (width, height) == (self.surface_width, self.surface_height) { + return; + } + self.surface_width = width; + self.surface_height = height; + self.view.set_size(width, height); + match self.context { + Context::None => {} + Context::TwoD(context) => { + canvas_c::resize(unsafe { &mut *context }, width as f32, height as f32) + } + Context::WebGL(state) => { + canvas_c::canvas_native_webgl_resize_pbuffer(state, width as i32, height as i32); + } + Context::WebGPU(context) => unsafe { + canvas_c::webgpu::gpu_canvas_context::canvas_native_webgpu_context_resize_offscreen( + context, width, height, + ) + }, + } + } + + /// The view's laid-out size in DIPs. + #[napi] + pub fn set_view_size(&mut self, width: f64, height: f64) { + self.view_width = width as f32; + self.view_height = height as f32; + } + + /// The view's scale factor (physical pixels per DIP). + #[napi] + pub fn set_composition_scale(&mut self, scale_x: f64, scale_y: f64) { + self.scale_x = scale_x as f32; + self.scale_y = scale_y as f32; + self.view.set_density(self.density()); + } + + /// Where the drawing buffer sits in the view, in DIPs: `[scaleX, scaleY, offsetX, offsetY]`. + #[napi(getter)] + pub fn surface_transform(&self) -> Vec { + let t = surface_transform( + self.fit, + (self.surface_width as f32, self.surface_height as f32), + (self.scale_x, self.scale_y), + (self.view_width, self.view_height), + ); + vec![ + t.scale_x as f64, + t.scale_y as f64, + t.offset_x as f64, + t.offset_y as f64, + ] + } + + /// Creates (once) the 2D context and returns its pointer as a decimal string, like the iOS view. + /// Arguments mirror `NSCCanvas.create2DContext` on iOS; alpha, fontColor and colorSpace apply. + #[napi(js_name = "create2DContext")] + pub fn create_2d_context( + &mut self, + alpha: bool, + _antialias: bool, + _depth: bool, + _fail_if_major_performance_caveat: bool, + _power_preference: i32, + _premultiplied_alpha: bool, + _preserve_drawing_buffer: bool, + _stencil: bool, + _desynchronized: bool, + _xr_compatible: bool, + font_color: i32, + _will_read_frequently: bool, + color_space: Option, + ) -> Result { + match self.context { + Context::TwoD(context) => return Ok((context as usize).to_string()), + Context::WebGL(_) | Context::WebGPU(_) => { + return Err(Error::from_reason( + "The canvas already has a WebGL or WebGPU context", + )) + } + Context::None => {} + } + let color_space = match color_space.unwrap_or(0) { + 1 => canvas_c::CanvasColorSpace::P3, + _ => canvas_c::CanvasColorSpace::Srgb, + }; + let (width, height) = (self.surface_width as f32, self.surface_height as f32); + let density = self.density(); + let mut context = canvas_c::canvas_native_context_create_gl( + std::ptr::null_mut(), + width, + height, + density, + alpha, + font_color, + density * 96., + 0, + color_space, + ); + if context.is_null() { + log::error!( + "canvas: no GLES context available; the 2D canvas draws on the CPU and is not shown" + ); + context = canvas_c::canvas_native_context_create( + width, + height, + density, + alpha, + font_color, + density * 96., + 0, + color_space, + ); + } + let egl_context = canvas_c::canvas_native_context_get_egl_context(context); + self.attach(Context::TwoD(context), egl_context, true); + Ok((context as usize).to_string()) + } + + /// Creates (once) the WebGL (`type` "webgl"/"experimental-webgl") or WebGL 2 context; + /// `nativeContext` then holds its pointer. Arguments mirror the iOS view's `initContext`. + #[napi] + pub fn init_context( + &mut self, + context_type: String, + alpha: bool, + antialias: bool, + depth: bool, + fail_if_major_performance_caveat: bool, + power_preference: i32, + premultiplied_alpha: bool, + preserve_drawing_buffer: bool, + stencil: bool, + desynchronized: bool, + xr_compatible: bool, + _is_canvas: Option, + _color_space: Option, + ) -> Result<()> { + match self.context { + Context::WebGL(_) => return Ok(()), + Context::TwoD(_) | Context::WebGPU(_) => { + return Err(Error::from_reason( + "The canvas already has a 2D or WebGPU context", + )) + } + Context::None => {} + } + let version = if context_type.contains("webgl2") { + 2 + } else { + 1 + }; + let state = canvas_c::canvas_native_webgl_create_no_window( + self.surface_width as i32, + self.surface_height as i32, + version, + alpha, + antialias, + depth, + fail_if_major_performance_caveat, + power_preference.max(0), + premultiplied_alpha, + preserve_drawing_buffer, + stencil, + desynchronized, + xr_compatible, + false, + ); + if state.is_null() { + return Err(Error::from_reason( + "WebGL is unavailable: no GLES context could be created on EGL", + )); + } + let egl_context = canvas_c::canvas_native_webgl_get_egl_context(state); + self.attach(Context::WebGL(state), egl_context, true); + Ok(()) + } + + /// Creates (once) the WebGPU context on the `GPU` instance `instance` (its `__getPointer()`); + /// `nativeContext` then holds its pointer. Frames are Vulkan images shown without copies. + #[napi(js_name = "initWebGPUContext")] + pub fn init_webgpu_context(&mut self, instance: napi::bindgen_prelude::BigInt) -> Result<()> { + match self.context { + Context::WebGPU(_) => return Ok(()), + Context::TwoD(_) | Context::WebGL(_) => { + return Err(Error::from_reason( + "The canvas already has a 2D or WebGL context", + )) + } + Context::None => {} + } + let (instance, _) = instance.get_i64(); + if instance == 0 { + return Err(Error::from_reason("Invalid GPU instance")); + } + let context = unsafe { + canvas_c::webgpu::gpu_canvas_context::canvas_native_webgpu_context_create_offscreen( + instance as *const _, + self.surface_width, + self.surface_height, + ) + }; + if context.is_null() { + return Err(Error::from_reason("Could not create a WebGPU context")); + } + let id = NEXT_VIEW.fetch_add(1, Ordering::Relaxed); + OFFSCREEN_VIEWS.with(|views| views.borrow_mut().insert(id, self.view.downgrade())); + self.offscreen_view = id; + let sink: canvas_c::webgpu::linux_frames::LinuxFrameSink = + Arc::new(move |frame| glib::MainContext::default().invoke(move || show_gpu_frame(id, frame))); + canvas_c::webgpu::linux_frames::canvas_native_webgpu_context_set_linux_sink( + context, + Some(sink), + ); + self.context = Context::WebGPU(context); + Ok(()) + } + + /// GL contexts are not lost on Linux (Mesa has no device loss to report here). + #[napi] + pub fn is_context_lost(&self) -> bool { + match self.context { + Context::WebGL(state) => canvas_c::canvas_native_webgl_get_is_context_lost(state), + _ => false, + } + } + + #[napi] + pub fn restore_context(&self) -> bool { + true + } + + /// Renders pending drawing and shows it now. + #[napi] + pub fn present(&self) { + match self.context { + Context::None => {} + Context::TwoD(context) => canvas_c::canvas_native_context_render(context), + // WebGPU presents through its context (presentSurface). + Context::WebGPU(_) => {} + Context::WebGL(state) => { + canvas_c::canvas_native_webgl_present(state); + } + } + if let Some(presenter) = self.presenter.as_ref() { + presenter.present(self.surface_width as i32, self.surface_height as i32); + } + } +} + +/// A GdkTexture's pixels as premultiplied RGBA8. +pub(crate) fn texture_pixels(texture: usize) -> Option<(u32, u32, Vec)> { + if texture == 0 || !gtk_ready() { + return None; + } + let texture = texture as *mut gdk::ffi::GdkTexture; + let (width, height) = unsafe { + ( + gdk::ffi::gdk_texture_get_width(texture), + gdk::ffi::gdk_texture_get_height(texture), + ) + }; + if width <= 0 || height <= 0 { + return None; + } + let stride = width as usize * 4; + let mut pixels = vec![0u8; stride * height as usize]; + // GDK_MEMORY_DEFAULT: premultiplied BGRA on little-endian machines. + unsafe { gdk::ffi::gdk_texture_download(texture, pixels.as_mut_ptr(), stride) }; + if cfg!(target_endian = "little") { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + } else { + for pixel in pixels.chunks_exact_mut(4) { + pixel.rotate_left(1); + } + } + Some((width as u32, height as u32, pixels)) +} + +/// A frame texture an OffscreenCanvas's rendering thread fills and GDK shows. +struct SharedSlot { + texture: u32, + framebuffer: u32, + image: usize, + width: i32, + height: i32, + format: u32, + /// GDK still holds a `GdkGLTexture` of it. + busy: Arc, + shown: u64, +} + +/// Shows a transferred canvas's frames: runs on the thread that renders its context and hands +/// each frame to the UI thread. +struct OffscreenPresenter { + view: u64, + images: Option, + slots: Mutex>, + frames: AtomicU64, +} + +static NEXT_VIEW: AtomicU64 = AtomicU64::new(1); + +thread_local! { + /// Views of transferred canvases, by id (UI thread). + static OFFSCREEN_VIEWS: RefCell>> = RefCell::new(HashMap::new()); + /// GDK textures of offscreen frame images (UI thread). + static IMPORTED: RefCell> = RefCell::new(HashMap::new()); +} + +impl OffscreenPresenter { + fn present(&self, width: i32, height: i32) { + if width <= 0 || height <= 0 { + return; + } + let view = self.view; + let Some(images) = self.images else { + // No shared display: copy the frame out. + let mut pixels = vec![0u8; width as usize * height as usize * 4]; + unsafe { read_pixels(width, height, &mut pixels) }; + glib::MainContext::default().invoke(move || { + let bytes = glib::Bytes::from_owned(pixels); + let frame = gdk::MemoryTexture::new( + width, + height, + gdk::MemoryFormat::R8g8b8a8Premultiplied, + &bytes, + width as usize * 4, + ); + if let Some(view) = offscreen_view(view) { + view.show(frame.upcast(), true); + } + }); + return; + }; + let egl_context = current_egl_context(); + let mut slots = self.slots.lock().unwrap_or_else(|e| e.into_inner()); + let saved = unsafe { SavedBindings::save() }; + let format = unsafe { frame_format() }; + let mut forgotten = Vec::new(); + slots.retain(|slot| { + let keep = (slot.width, slot.height, slot.format) == (width, height, format) + || slot.busy.load(Ordering::Acquire); + if !keep { + unsafe { + gl_bindings::DeleteFramebuffers(1, &slot.framebuffer); + gl_bindings::DeleteTextures(1, &slot.texture); + (images.destroy)(images.display(), slot.image as *mut c_void); + } + forgotten.push(slot.image); + } + keep + }); + let fits = + |slot: &SharedSlot| (slot.width, slot.height, slot.format) == (width, height, format); + let index = match slots + .iter() + .position(|s| fits(s) && !s.busy.load(Ordering::Acquire)) + { + Some(index) => Some(index), + None if slots.iter().filter(|s| fits(s)).count() < MAX_SLOTS => { + unsafe { create_slot(&images, egl_context, width, height, format) }.map(|slot| { + slots.push(SharedSlot { + texture: slot.texture, + framebuffer: slot.framebuffer, + image: slot.image as usize, + width, + height, + format, + busy: Arc::new(AtomicBool::new(false)), + shown: 0, + }); + slots.len() - 1 + }) + } + // GDK holds them all: skip the frame, the next one comes soon. + None => None, + }; + let Some(index) = index else { + unsafe { saved.restore() }; + return; + }; + let slot = &mut slots[index]; + unsafe { + gl_bindings::BindFramebuffer(gl_bindings::READ_FRAMEBUFFER, 0); + gl_bindings::BindFramebuffer(gl_bindings::DRAW_FRAMEBUFFER, slot.framebuffer); + gl_bindings::Disable(gl_bindings::SCISSOR_TEST); + gl_bindings::BlitFramebuffer( + 0, + 0, + width, + height, + 0, + 0, + width, + height, + gl_bindings::COLOR_BUFFER_BIT, + gl_bindings::NEAREST, + ); + saved.restore(); + images.finish(); + } + let frames = self.frames.fetch_add(1, Ordering::Relaxed) + 1; + slot.shown = frames; + slot.busy.store(true, Ordering::Release); + let (image, busy) = (slot.image, slot.busy.clone()); + drop(slots); + glib::MainContext::default().invoke(move || { + for image in forgotten { + forget_image(image); + } + show_offscreen(view, images, image, width, height, busy); + }); + } +} + +fn offscreen_view(id: u64) -> Option { + OFFSCREEN_VIEWS.with(|views| views.borrow().get(&id).and_then(|view| view.upgrade())) +} + +/// UI thread: `image` (complete) as a GdkGLTexture in its view. +fn show_offscreen( + view: u64, + images: Images, + image: usize, + width: i32, + height: i32, + busy: Arc, +) { + let shown = with_gdk(|gdk| { + let context = gdk.context.as_ref()?; + let view = offscreen_view(view)?; + context.make_current(); + let texture = IMPORTED.with(|imported| { + let mut imported = imported.borrow_mut(); + if let Some(texture) = imported.get(&image) { + return Some(*texture); + } + let texture = unsafe { import_image(&images, image as *mut c_void) }?; + imported.insert(image, texture); + Some(texture) + })?; + let released = busy.clone(); + let frame = unsafe { + gdk::GLTexture::with_release_func(context, texture, width, height, move || { + released.store(false, Ordering::Release) + }) + }; + view.show(frame.upcast(), true); + Some(()) + }); + if shown.is_none() { + busy.store(false, Ordering::Release); + } +} + +fn forget_image(image: usize) { + if let Some(texture) = IMPORTED.with(|imported| imported.borrow_mut().remove(&image)) { + with_gdk(|gdk| { + if let Some(context) = gdk.context.as_ref() { + context.make_current(); + unsafe { gl_bindings::DeleteTextures(1, &texture) }; + } + }); + } +} + +/// UI thread: a surface whose contexts' frames show in `view`. +fn create_offscreen_surface( + view: &CanvasView, + width: u32, + height: u32, + density: f32, +) -> (u64, *const canvas_c::offscreen::CanvasOffscreenSurface) { + let id = NEXT_VIEW.fetch_add(1, Ordering::Relaxed); + OFFSCREEN_VIEWS.with(|views| views.borrow_mut().insert(id, view.downgrade())); + let presenter = Arc::new(OffscreenPresenter { + view: id, + images: with_gdk(|gdk| gdk.images), + slots: Mutex::new(Vec::new()), + frames: AtomicU64::new(0), + }); + let hook: canvas_core::gpu::gl::PresentHook = + Arc::new(move |width, height| presenter.present(width, height)); + let surface = canvas_c::offscreen::canvas_native_offscreen_surface_create_linux( + width, + height, + density, + density * 96., + 0, + canvas_c::CanvasColorSpace::Srgb, + hook, + ); + (id, surface) +} + +fn drop_offscreen_view(id: u64) { + OFFSCREEN_VIEWS.with(|views| views.borrow_mut().remove(&id)); +} + +const GL_TEXTURE_TILING_EXT: u32 = 0x9580; +const GL_DEDICATED_MEMORY_OBJECT_EXT: u32 = 0x9581; +const GL_OPTIMAL_TILING_EXT: i32 = 0x9584; +const GL_HANDLE_TYPE_OPAQUE_FD_EXT: u32 = 0x9586; +const GL_TEXTURE_SWIZZLE_R: u32 = 0x8E42; +const GL_TEXTURE_SWIZZLE_B: u32 = 0x8E44; + +/// `GL_EXT_memory_object(_fd)` entry points, for Vulkan images. +#[derive(Clone, Copy)] +struct MemoryObjects { + create: unsafe extern "C" fn(i32, *mut u32), + parameter: unsafe extern "C" fn(u32, u32, *const i32), + import: unsafe extern "C" fn(u32, u64, u32, i32), + storage: unsafe extern "C" fn(u32, i32, u32, i32, i32, u32, u64), + delete: unsafe extern "C" fn(i32, *const u32), +} + +thread_local! { + static MEMORY_OBJECTS: OnceCell> = const { OnceCell::new() }; + /// GDK textures (and their memory objects) of WebGPU frame images (UI thread). + static GPU_IMPORTED: RefCell> = RefCell::new(HashMap::new()); +} + +fn memory_objects() -> Option { + MEMORY_OBJECTS.with(|cell| { + *cell.get_or_init(|| { + let objects = Some(MemoryObjects { + create: proc_address("glCreateMemoryObjectsEXT")?, + parameter: proc_address("glMemoryObjectParameterivEXT")?, + import: proc_address("glImportMemoryFdEXT")?, + storage: proc_address("glTexStorageMem2DEXT")?, + delete: proc_address("glDeleteMemoryObjectsEXT")?, + }); + if objects.is_none() { + log::error!( + "canvas: GDK's GL lacks GL_EXT_memory_object_fd; WebGPU canvases are not shown" + ); + } + objects + }) + }) +} + +/// UI thread, GDK's context current: the image's memory as a GL texture (taking `fd`). +unsafe fn import_gpu_image( + objects: &MemoryObjects, + frame: &canvas_c::webgpu::linux_frames::LinuxGpuFrame, +) -> Option<(u32, u32)> { + unsafe { + while gl_bindings::GetError() != gl_bindings::NO_ERROR {} + let mut memory = 0; + (objects.create)(1, &mut memory); + let dedicated = 1; + (objects.parameter)(memory, GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); + (objects.import)( + memory, + frame.allocation_size, + GL_HANDLE_TYPE_OPAQUE_FD_EXT, + frame.fd, + ); + let mut texture = 0; + gl_bindings::GenTextures(1, &mut texture); + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, texture); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + GL_TEXTURE_TILING_EXT, + GL_OPTIMAL_TILING_EXT, + ); + (objects.storage)( + gl_bindings::TEXTURE_2D, + 1, + gl_bindings::RGBA8, + frame.width as i32, + frame.height as i32, + memory, + 0, + ); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MIN_FILTER, + gl_bindings::LINEAR as i32, + ); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + gl_bindings::TEXTURE_MAG_FILTER, + gl_bindings::LINEAR as i32, + ); + if frame.bgra { + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + GL_TEXTURE_SWIZZLE_R, + gl_bindings::BLUE as i32, + ); + gl_bindings::TexParameteri( + gl_bindings::TEXTURE_2D, + GL_TEXTURE_SWIZZLE_B, + gl_bindings::RED as i32, + ); + } + gl_bindings::BindTexture(gl_bindings::TEXTURE_2D, 0); + if gl_bindings::GetError() != gl_bindings::NO_ERROR { + gl_bindings::DeleteTextures(1, &texture); + (objects.delete)(1, &memory); + log::error!("canvas: GDK's GL could not import a WebGPU frame image"); + return None; + } + Some((texture, memory)) + } +} + +/// UI thread: a finished WebGPU frame in its view. +fn show_gpu_frame(view: u64, frame: canvas_c::webgpu::linux_frames::LinuxGpuFrame) { + let busy = frame.busy.clone(); + let shown = with_gdk(|gdk| { + let context = gdk.context.as_ref()?; + let objects = memory_objects()?; + context.make_current(); + GPU_IMPORTED.with(|imported| { + let mut imported = imported.borrow_mut(); + for image in &frame.forget { + if let Some((texture, memory)) = imported.remove(image) { + unsafe { + gl_bindings::DeleteTextures(1, &texture); + (objects.delete)(1, &memory); + } + } + } + if frame.fd >= 0 { + let entry = unsafe { import_gpu_image(&objects, &frame) }?; + imported.insert(frame.image, entry); + } + let view = offscreen_view(view)?; + let (texture, _) = *imported.get(&frame.image)?; + let released = busy.clone(); + let texture = unsafe { + gdk::GLTexture::with_release_func( + context, + texture, + frame.width as i32, + frame.height as i32, + move || released.store(false, Ordering::Release), + ) + }; + view.show(texture.upcast(), false); + Some(()) + }) + }); + if shown.is_none() { + busy.store(false, Ordering::Release); + } +} diff --git a/crates/canvas-napi/src/host/mod.rs b/crates/canvas-napi/src/host/mod.rs index 10c1108c0..49742c4fe 100644 --- a/crates/canvas-napi/src/host/mod.rs +++ b/crates/canvas-napi/src/host/mod.rs @@ -1,5 +1,8 @@ -//! The native side of the canvas view on each desktop host. Windows (WinUI 3 `SwapChainPanel`) is -//! the first; macOS/Linux hosts add a module here with the same `NSCCanvas` surface. +//! The native side of the canvas view on each desktop host: Windows (WinUI 3 `SwapChainPanel`) +//! and Linux (a GTK 4 widget), with the same `NSCCanvas` surface. #[cfg(target_os = "windows")] pub mod windows; + +#[cfg(target_os = "linux")] +pub mod linux; diff --git a/crates/canvas-napi/src/image_asset.rs b/crates/canvas-napi/src/image_asset.rs index ba8d08418..072390794 100644 --- a/crates/canvas-napi/src/image_asset.rs +++ b/crates/canvas-napi/src/image_asset.rs @@ -408,3 +408,14 @@ impl ImageAsset { }) } } + +#[cfg(target_os = "linux")] +#[napi] +impl ImageAsset { + /// A GdkTexture (core's `ImageSource.linux`, by address): its pixels, downloaded once. + #[napi] + pub fn from_gdk_texture_sync(&self, texture: f64) -> bool { + crate::host::linux::texture_pixels(texture as usize) + .is_some_and(|(width, height, pixels)| load_raw(&self.asset, width, height, &pixels, true)) + } +} diff --git a/crates/canvas-napi/src/lib.rs b/crates/canvas-napi/src/lib.rs index 51d8ccefa..077e0e3a2 100644 --- a/crates/canvas-napi/src/lib.rs +++ b/crates/canvas-napi/src/lib.rs @@ -31,6 +31,8 @@ mod module; pub fn install_global(exports: napi::bindgen_prelude::Object, env: napi::Env) -> napi::Result<()> { use napi::bindgen_prelude::JsObjectValue; logger::install(env.raw())?; + #[cfg(target_os = "linux")] + host::linux::init(); frame::install_microtask_scheduler(env.raw())?; fast::install(env.raw(), napi::JsValue::raw(&exports))?; install_canvases_behind(env.raw(), napi::JsValue::raw(&exports)); diff --git a/crates/canvas-svg-napi/Cargo.toml b/crates/canvas-svg-napi/Cargo.toml index 0ed957037..947a6c26b 100644 --- a/crates/canvas-svg-napi/Cargo.toml +++ b/crates/canvas-svg-napi/Cargo.toml @@ -3,7 +3,7 @@ name = "canvas-svg-napi" version = "0.1.0" edition = "2021" publish = false -description = "Node-API binding for @nativescript/canvas-svg: the global.SVGModule the V8 bindings install, for Node-API hosts (NativeScript Windows, Node)." +description = "Node-API binding for @nativescript/canvas-svg: the global.SVGModule the V8 bindings install, for Node-API hosts (NativeScript Windows and Linux, Node)." [lib] crate-type = ["cdylib"] diff --git a/crates/canvas-webgl/src/prelude.rs b/crates/canvas-webgl/src/prelude.rs index 25f644f29..70045b44a 100644 --- a/crates/canvas-webgl/src/prelude.rs +++ b/crates/canvas-webgl/src/prelude.rs @@ -311,7 +311,7 @@ impl WebGLState { } } - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "linux"))] pub fn resize_pbuffer(&mut self, width: i32, height: i32) { // A new pbuffer starts blank; layout calls this without a size change. if self.context.is_pbuffer() && self.context.get_surface_dimensions() == (width, height) { @@ -683,6 +683,10 @@ impl WebGLState { self.context.resize_texture_surface(width, height) } + pub fn gl_context(&self) -> &canvas_core::gpu::gl::GLContext { + &self.context + } + pub fn make_current_and_swap_buffers(&self) -> bool { let made = self.context.make_current(); let swapped = self.context.swap_buffers(); diff --git a/crates/canvas-webgl/src/webgl.rs b/crates/canvas-webgl/src/webgl.rs index 5f962e092..627bb0fd0 100644 --- a/crates/canvas-webgl/src/webgl.rs +++ b/crates/canvas-webgl/src/webgl.rs @@ -1075,6 +1075,19 @@ pub fn canvas_native_webgl_get_extension( state.make_current(); let version = state.get_webgl_version(); + + #[cfg(target_os = "linux")] + if version == WebGLVersion::V1 && es3_core() { + match name { + "ANGLE_instanced_arrays" => return Some(Box::new(ANGLE_instanced_arrays::new(state))), + "OES_vertex_array_object" => { + return Some(Box::new(OES_vertex_array_object::new(state))) + } + "WEBGL_draw_buffers" => return Some(Box::new(WEBGL_draw_buffers::new(state))), + _ => {} + } + } + let extensions = unsafe { gl_bindings::GetString(gl_bindings::EXTENSIONS) }; if extensions.is_null() { @@ -1703,10 +1716,32 @@ pub fn canvas_native_webgl_get_supported_extensions(state: &mut WebGLState) -> V let version = unsafe { String::from_utf8(CStr::from_ptr(ext).to_bytes().to_vec()).unwrap() }; - version.split(" ").map(|f| f.into()).collect() + #[allow(unused_mut)] + let mut supported: Vec = version.split(" ").map(|f| f.into()).collect(); + #[cfg(target_os = "linux")] + if state.get_webgl_version() == WebGLVersion::V1 && es3_core() { + supported.extend( + [ + "ANGLE_instanced_arrays", + "OES_vertex_array_object", + "WEBGL_draw_buffers", + ] + .map(String::from), + ); + } + supported } } +/// Linux (Mesa): a WebGL 1 context is still an ES 3 context, whose core has instancing, vertex +/// array objects and draw buffers. +#[cfg(target_os = "linux")] +fn es3_core() -> bool { + let mut major = 0; + unsafe { gl_bindings::GetIntegerv(gl_bindings::MAJOR_VERSION, &mut major) }; + major >= 3 +} + pub fn canvas_native_webgl_get_tex_parameter( target: u32, pname: u32, diff --git a/packages/audio-context/README.md b/packages/audio-context/README.md index eac434855..dd184b93b 100644 --- a/packages/audio-context/README.md +++ b/packages/audio-context/README.md @@ -1,6 +1,6 @@ # @nativescript/audio-context -Web Audio API style audio graph support for NativeScript (iOS, Android and Windows). +Web Audio API style audio graph support for NativeScript (iOS, Android, Windows and Linux). This package provides a native-backed `AudioContext`/`OfflineAudioContext` with common Web Audio nodes so you can keep a familiar Web Audio programming model in NativeScript apps. @@ -161,6 +161,11 @@ reads wav, mp3, ogg/vorbis, flac and aac. Differences from iOS and Android: - A `WaveShaperNode` takes one curve; assigning a second one throws `InvalidStateError`. - `sinkId` is `''` for the default device; `'none'` renders without a device. +## Linux + +Linux uses the same engine and module as Windows (`audiocontext.node`), playing through ALSA +(PulseAudio and PipeWire through their ALSA plugins). The differences listed for Windows apply. + ## Optional: Global Polyfill Integration If you also use `@nativescript/canvas-polyfill`, it can register Web Audio globals such as: diff --git a/packages/audio-context/index.linux.ts b/packages/audio-context/index.linux.ts new file mode 100644 index 000000000..e63b48792 --- /dev/null +++ b/packages/audio-context/index.linux.ts @@ -0,0 +1 @@ +export * from './napi'; diff --git a/packages/audio-context/index.windows.ts b/packages/audio-context/index.windows.ts index 4f71c2c42..e63b48792 100644 --- a/packages/audio-context/index.windows.ts +++ b/packages/audio-context/index.windows.ts @@ -1,1239 +1 @@ -import { - AnalyserOptions, - AudioBufferCopyOptions, - AudioContextOptions, - AudioContextState, - AudioListenerBase, - AudioNodeBase, - AudioParamBase, - AudioParamHooks, - BaseAudioContext, - ChannelMergerOptions, - ChannelSplitterOptions, - ConstantSourceOptions, - ConvolverOptions, - DelayOptions, - DistanceModelType, - DynamicsCompressorOptions, - IIRFilterOptions, - MediaElementLike, - PanningModelType, - PannerOptions, - PeriodicWaveOptions, - StereoPannerOptions, - WaveShaperOptions, - assertMediaElementUsable, - context_, - distanceModelFromNumber, - distanceModelToNumber, - looksLikePath, - markMediaElementUsed, - native_, - nativeCtor_, - normalizeSourcePath, - panningModelFromNumber, - panningModelToNumber, - resolveLatencyHint, - throwInvalidMediaElement, - unmarkMediaElementUsed, -} from './common'; - -declare const __non_webpack_require__: (specifier: string) => any; - -interface NativeParam { - value: number; - readonly defaultValue: number; - readonly minValue: number; - readonly maxValue: number; - automationRate: string; - setValueAtTime(value: number, time: number): void; - linearRampToValueAtTime(value: number, time: number): void; - exponentialRampToValueAtTime(value: number, time: number): void; - setTargetAtTime(value: number, startTime: number, timeConstant: number): void; - setValueCurveAtTime(values: Float32Array, startTime: number, duration: number): void; - cancelScheduledValues(time: number): void; - cancelAndHoldAtTime(time: number): void; -} - -interface NativeBuffer { - readonly sampleRate: number; - readonly length: number; - readonly duration: number; - readonly numberOfChannels: number; - channelData(channel: number): Float32Array; - copyFromChannel(destination: Float32Array, channel: number, startInChannel: number): void; -} - -/** Any node; members a kind does not have throw. */ -interface NativeNode { - readonly numberOfInputs: number; - readonly numberOfOutputs: number; - channelCount: number; - channelCountMode: string; - channelInterpretation: string; - connect(destination: NativeNode, output?: number, input?: number): void; - connectParam(destination: NativeParam, output?: number): void; - disconnect(): void; - disconnectOutput(output: number): void; - disconnectNode(destination: NativeNode, output?: number, input?: number): void; - disconnectParam(destination: NativeParam, output?: number): void; - param(name: string): NativeParam | null; - [member: string]: any; -} - -interface NativeContext { - readonly sampleRate: number; - readonly currentTime: number; - readonly state: AudioContextState; - readonly baseLatency: number; - readonly outputLatency: number; - readonly sinkId: string; - readonly length: number; - setOnstatechange(callback: (() => void) | null): void; - resume(): Promise; - suspend(): Promise; - close(): Promise; - setSinkId(sinkId: string): Promise; - startRendering(): Promise; - decodeAudioData(data: Uint8Array): Promise; - decodeAudioBase64(data: string): Promise; - decodeAudioFile(path: string): Promise; - destination(): NativeNode; - listenerParam(name: string): NativeParam | null; - createPeriodicWave(real: Float32Array, imag: Float32Array, disableNormalization: boolean): unknown; - [factory: string]: any; -} - -// Loaded on import, so a missing module leaves AudioContext undefined in canvas-polyfill's probe. -const Native = __non_webpack_require__('system_lib://audiocontext.node'); - -type NativeBaseAudioContext = BaseAudioContext & { native: NativeContext }; - -function nativeContext(context: BaseAudioContext): NativeContext { - const native = (context as NativeBaseAudioContext)?.native; - if (!(context instanceof BaseAudioContext) || !native) { - throw new TypeError('Argument 1 does not implement interface BaseAudioContext.'); - } - return native; -} - -function toFloat32Array(values: Float32Array | ArrayLike): Float32Array { - return values instanceof Float32Array ? values : Float32Array.from(values); -} - -function normalizeCopyByteOffset(view: ArrayBufferView, byteOffset?: number): number { - let offset = typeof byteOffset === 'number' && Number.isFinite(byteOffset) ? byteOffset : view.byteOffset; - offset = Math.max(0, offset | 0); - if (offset > view.buffer.byteLength) offset = view.buffer.byteLength; - const align = ((view as any).BYTES_PER_ELEMENT as number) || 1; - return offset - (offset % align); -} - -function resolveAudioBufferCopyOptions(view: ArrayBufferView, startOrOptions?: number | AudioBufferCopyOptions): { startInChannel: number; byteOffset: number } { - let startInChannel = 0; - let byteOffset: number | undefined; - if (typeof startOrOptions === 'number') { - startInChannel = startOrOptions; - } else if (startOrOptions) { - if (typeof startOrOptions.startInChannel === 'number') startInChannel = startOrOptions.startInChannel; - if (typeof startOrOptions.byteOffset === 'number') byteOffset = startOrOptions.byteOffset; - } - if (!Number.isFinite(startInChannel)) startInChannel = 0; - startInChannel = Math.max(0, startInChannel | 0); - return { - startInChannel, - byteOffset: normalizeCopyByteOffset(view, byteOffset), - }; -} - -function viewAtByteOffset(view: Float32Array, byteOffset: number): Float32Array { - if (byteOffset === view.byteOffset) return view; - if (byteOffset >= view.buffer.byteLength) return new Float32Array(0); - const availableBytes = view.buffer.byteLength - byteOffset; - const length = Math.floor(availableBytes / Float32Array.BYTES_PER_ELEMENT); - if (length <= 0) return new Float32Array(0); - return new Float32Array(view.buffer, byteOffset, length); -} - -/** `file:///C:/x` and `/C:/x` name a drive path on Windows. */ -function windowsPath(source: string): string { - const path = normalizeSourcePath(source.startsWith('file:///') ? source.substring('file://'.length) : source); - return /^\/[a-zA-Z]:[\\/]/.test(path) ? path.substring(1) : path; -} - -function makeWindowsHooks(native: NativeParam): AudioParamHooks { - return { - nativeSet(v) { - native.value = v; - }, - nativeScheduleSet(v, t) { - native.setValueAtTime(v, t); - }, - nativeScheduleLinearRamp(v, t) { - native.linearRampToValueAtTime(v, t); - }, - nativeScheduleExpRamp(v, t) { - native.exponentialRampToValueAtTime(v, t); - }, - nativeScheduleTarget(v, startTime, timeConstant) { - native.setTargetAtTime(v, startTime, timeConstant); - }, - nativeScheduleCurve(values, startTime, duration) { - native.setValueCurveAtTime(values, startTime, duration); - }, - nativeCancel(t) { - native.cancelScheduledValues(t); - }, - nativeCancelAndHold(_v, t) { - native.cancelAndHoldAtTime(t); - }, - nativeGetValue() { - return native.value; - }, - nativeSetAutomationRate(rate) { - native.automationRate = rate; - }, - }; -} - -export class AudioParam extends AudioParamBase { - private [native_]: NativeParam; - - constructor(a?: any, b?: NativeParam) { - if (a !== nativeCtor_ || !b) { - throw new TypeError('Illegal constructor.'); - } - super(makeWindowsHooks(b), b.value); - this[native_] = b; - } - - get native() { - return this[native_]; - } - - /** The value the render thread last computed, automation included. */ - get value(): number { - return this[native_].value; - } - - set value(v: number) { - this[native_].value = +v; - } - - get defaultValue(): number { - return this[native_].defaultValue; - } - - get minValue(): number { - return this[native_].minValue; - } - - get maxValue(): number { - return this[native_].maxValue; - } - - get automationRate(): string { - return this[native_].automationRate; - } - - set automationRate(v: string) { - this[native_].automationRate = v; - } -} - -function param(node: NativeNode, name: string): AudioParam { - return new AudioParam(nativeCtor_, node.param(name)); -} - -export class AudioNode extends AudioNodeBase { - protected [native_]: NativeNode; - private _params: { [name: string]: AudioParam } = {}; - - constructor(context: BaseAudioContext, native: NativeNode) { - super(context); - this[native_] = native; - } - - get native() { - return this[native_]; - } - - /** The node's `name` param, one wrapper per param. */ - protected _param(name: string): AudioParam { - return this._params[name] || (this._params[name] = param(this[native_], name)); - } - - get numberOfInputs(): number { - return this[native_].numberOfInputs; - } - - get numberOfOutputs(): number { - return this[native_].numberOfOutputs; - } - - get channelCount(): number { - return this[native_].channelCount; - } - - set channelCount(value: number) { - this[native_].channelCount = value; - } - - get channelCountMode(): string { - return this[native_].channelCountMode; - } - - set channelCountMode(value: string) { - this[native_].channelCountMode = value; - } - - get channelInterpretation(): string { - return this[native_].channelInterpretation; - } - - set channelInterpretation(value: string) { - this[native_].channelInterpretation = value; - } - - connect(destination: any, output?: number, input?: number) { - if (destination instanceof AudioParam) { - this[native_].connectParam(destination.native, output); - return; - } - if (!(destination instanceof AudioNode)) { - throw new TypeError('AudioNode.connect: Argument 1 is not an AudioNode or AudioParam.'); - } - this[native_].connect(destination.native, output, input); - return destination; - } - - disconnect(destinationOrOutput?: any, output?: number, input?: number) { - const native = this[native_]; - if (destinationOrOutput == null) { - native.disconnect(); - } else if (typeof destinationOrOutput === 'number') { - native.disconnectOutput(destinationOrOutput); - } else if (destinationOrOutput instanceof AudioParam) { - native.disconnectParam(destinationOrOutput.native, output); - } else if (destinationOrOutput instanceof AudioNode) { - native.disconnectNode(destinationOrOutput.native, output, input); - } else { - throw new TypeError('AudioNode.disconnect: Argument 1 is not an AudioNode or AudioParam.'); - } - } -} - -/** - * Samples live in the JS channel arrays once read or written (`getChannelData` hands those out - * for in-place writes); the native buffer a node plays is rebuilt from them when it acquires one. - */ -export class AudioBuffer { - private _native: NativeBuffer | null = null; - private _channels: Float32Array[] | null = null; - private _dirty = false; - private readonly _sampleRate: number; - private readonly _length: number; - private readonly _numberOfChannels: number; - - constructor(options: { length: number; numberOfChannels?: number; sampleRate?: number } | Symbol, nativeBuffer?: NativeBuffer) { - if (!options) throw new TypeError('AudioBuffer constructor requires options'); - if (options === nativeCtor_ && nativeBuffer) { - this._native = nativeBuffer; - this._sampleRate = nativeBuffer.sampleRate; - this._length = nativeBuffer.length; - this._numberOfChannels = nativeBuffer.numberOfChannels; - return; - } - const opts = options as { length: number; numberOfChannels?: number; sampleRate?: number }; - const length = Math.max(0, opts.length | 0); - const channels = opts.numberOfChannels != null ? opts.numberOfChannels | 0 : 1; - const sampleRate = opts.sampleRate != null ? +opts.sampleRate : 48000; - // The native constructor applies the spec's limits. - this._native = new Native.AudioBuffer(channels, length, sampleRate); - this._sampleRate = sampleRate; - this._length = length; - this._numberOfChannels = channels; - } - - /** The samples as a native buffer, rebuilt after JS writes. */ - get native(): NativeBuffer { - if (this._dirty || !this._native) { - this._native = Native.AudioBuffer.fromChannels(this._channels, this._sampleRate); - this._dirty = false; - } - return this._native; - } - - get sampleRate(): number { - return this._sampleRate; - } - get length(): number { - return this._length; - } - get duration(): number { - return this._length / this._sampleRate; - } - get numberOfChannels(): number { - return this._numberOfChannels; - } - - private _channel(channel: number): Float32Array { - if (!(channel >= 0 && channel < this._numberOfChannels)) { - throw new RangeError(`IndexSizeError: channel ${channel} is out of bounds (${this._numberOfChannels} channels)`); - } - if (!this._channels) { - const native = this._native; - this._channels = []; - for (let i = 0; i < this._numberOfChannels; i++) this._channels.push(native.channelData(i)); - } - return this._channels[channel | 0]; - } - - getChannelData(channel: number): Float32Array { - const data = this._channel(channel); - this._dirty = true; - return data; - } - - copyFromChannel(dest: Float32Array, channel: number, startInChannel: number | AudioBufferCopyOptions = 0) { - if (!dest) return; - const options = resolveAudioBufferCopyOptions(dest, startInChannel); - const target = viewAtByteOffset(dest, options.byteOffset); - if (target.length === 0 || options.startInChannel >= this._length) return; - if (this._channels) { - const source = this._channel(channel); - target.set(source.subarray(options.startInChannel, options.startInChannel + target.length)); - } else { - this._native.copyFromChannel(target, channel, options.startInChannel); - } - } - - copyToChannel(source: Float32Array, channel: number, startInChannel: number | AudioBufferCopyOptions = 0) { - if (!source) return; - const options = resolveAudioBufferCopyOptions(source, startInChannel); - const input = viewAtByteOffset(source, options.byteOffset); - if (input.length === 0 || options.startInChannel >= this._length) return; - const target = this._channel(channel); - target.set(input.subarray(0, this._length - options.startInChannel), options.startInChannel); - this._dirty = true; - } -} - -export class GainNode extends AudioNode { - constructor(context: BaseAudioContext, options: { gain?: number } = {}) { - super(context, nativeContext(context).createGain()); - if (typeof options?.gain === 'number') this.gain.value = options.gain; - } - - get gain(): AudioParam { - return this._param('gain'); - } -} - -export class AudioDestinationNode extends AudioNode { - constructor(context: BaseAudioContext, node: NativeNode) { - super(context, node); - } - - get maxChannelCount(): number { - return this.native.maxChannelCount; - } -} - -export class BiquadFilterNode extends AudioNode { - constructor(context: BaseAudioContext, options: { type?: string; frequency?: number; Q?: number; gain?: number; detune?: number } = {}) { - super(context, nativeContext(context).createBiquadFilter()); - if (options.type) this.type = options.type; - if (typeof options.frequency === 'number') this.frequency.value = options.frequency; - if (typeof options.Q === 'number') this.Q.value = options.Q; - if (typeof options.gain === 'number') this.gain.value = options.gain; - if (typeof options.detune === 'number') this.detune.value = options.detune; - } - - get type(): string { - return this.native.type; - } - set type(value: string) { - this.native.type = value; - } - get frequency(): AudioParam { - return this._param('frequency'); - } - get Q(): AudioParam { - return this._param('Q'); - } - get gain(): AudioParam { - return this._param('gain'); - } - get detune(): AudioParam { - return this._param('detune'); - } - - getFrequencyResponse(frequencyHz: Float32Array, magResponse: Float32Array, phaseResponse: Float32Array) { - this.native.getFrequencyResponse(frequencyHz, magResponse, phaseResponse); - } -} - -export class PannerNode extends AudioNode { - constructor(context: BaseAudioContext, options: PannerOptions = {}) { - super(context, nativeContext(context).createPanner()); - const o = options ?? {}; - for (const name of ['positionX', 'positionY', 'positionZ', 'orientationX', 'orientationY', 'orientationZ']) { - if (typeof o[name] === 'number') this._param(name).value = o[name]; - } - if (o.panningModel != null) this.panningModel = o.panningModel; - if (o.distanceModel != null) this.distanceModel = o.distanceModel; - for (const name of ['refDistance', 'maxDistance', 'rolloffFactor', 'coneInnerAngle', 'coneOuterAngle', 'coneOuterGain']) { - if (typeof o[name] === 'number') this[name] = o[name]; - } - } - - get positionX() { - return this._param('positionX'); - } - get positionY() { - return this._param('positionY'); - } - get positionZ() { - return this._param('positionZ'); - } - get orientationX() { - return this._param('orientationX'); - } - get orientationY() { - return this._param('orientationY'); - } - get orientationZ() { - return this._param('orientationZ'); - } - - get distanceModel(): DistanceModelType { - return this.native.distanceModel; - } - set distanceModel(v: DistanceModelType | number) { - this.native.distanceModel = distanceModelFromNumber(distanceModelToNumber(v)); - } - get panningModel(): PanningModelType { - return this.native.panningModel; - } - set panningModel(v: PanningModelType | number) { - this.native.panningModel = panningModelFromNumber(panningModelToNumber(v)); - } - get refDistance(): number { - return this.native.refDistance; - } - set refDistance(v: number) { - this.native.refDistance = v; - } - get maxDistance(): number { - return this.native.maxDistance; - } - set maxDistance(v: number) { - this.native.maxDistance = v; - } - get rolloffFactor(): number { - return this.native.rolloffFactor; - } - set rolloffFactor(v: number) { - this.native.rolloffFactor = v; - } - get coneInnerAngle(): number { - return this.native.coneInnerAngle; - } - set coneInnerAngle(v: number) { - this.native.coneInnerAngle = v; - } - get coneOuterAngle(): number { - return this.native.coneOuterAngle; - } - set coneOuterAngle(v: number) { - this.native.coneOuterAngle = v; - } - get coneOuterGain(): number { - return this.native.coneOuterGain; - } - set coneOuterGain(v: number) { - this.native.coneOuterGain = v; - } - - setPosition(x: number, y: number, z: number) { - this.positionX.value = +x; - this.positionY.value = +y; - this.positionZ.value = +z; - } - setOrientation(x: number, y: number, z: number) { - this.orientationX.value = +x; - this.orientationY.value = +y; - this.orientationZ.value = +z; - } -} - -export class AudioScheduledSourceNode extends AudioNode { - private _onended: ((ev: { type: 'ended' }) => void) | null = null; - private _nativeEndedWired = false; - - constructor(context: BaseAudioContext, node: NativeNode) { - super(context, node); - if (!node || typeof node.start !== 'function') throw new TypeError('Illegal constructor.'); - } - - get onended() { - return this._onended; - } - set onended(cb: ((ev: { type: 'ended' }) => void) | null) { - if (this._onended) super.removeEventListener('ended', this._onended); - this._onended = typeof cb === 'function' ? cb : null; - if (this._onended) { - super.addEventListener('ended', this._onended); - this._ensureNativeEndedWired(); - } - } - - protected _onFirstListenerAdded(type: string) { - if (type === 'ended') this._ensureNativeEndedWired(); - } - - private _ensureNativeEndedWired() { - if (this._nativeEndedWired) return; - this._nativeEndedWired = true; - // Holds this node until it ends, as a playing node with a listener must stay reachable. - this.native.setOnended(() => this.dispatchEvent({ type: 'ended', target: this })); - } - - start(when?: number, offset?: number, duration?: number) { - this.native.start(when, offset, duration); - } - - stop(when?: number) { - this.native.stop(when); - } -} - -export class AudioBufferSourceNode extends AudioScheduledSourceNode { - private _buffer: AudioBuffer | null = null; - - constructor(context: BaseAudioContext, options: { buffer?: AudioBuffer; loop?: boolean; loopStart?: number; loopEnd?: number; playbackRate?: number; detune?: number } = {}) { - super(context, nativeContext(context).createBufferSource()); - const o = options ?? {}; - if (o.buffer) this.buffer = o.buffer; - if (typeof o.loop === 'boolean') this.loop = o.loop; - if (typeof o.loopStart === 'number') this.loopStart = o.loopStart; - if (typeof o.loopEnd === 'number') this.loopEnd = o.loopEnd; - if (typeof o.playbackRate === 'number') this.playbackRate.value = o.playbackRate; - if (typeof o.detune === 'number') this.detune.value = o.detune; - } - - get loop(): boolean { - return this.native.loop; - } - set loop(v: boolean) { - this.native.loop = !!v; - } - get loopStart(): number { - return this.native.loopStart; - } - set loopStart(v: number) { - this.native.loopStart = v; - } - get loopEnd(): number { - return this.native.loopEnd; - } - set loopEnd(v: number) { - this.native.loopEnd = v; - } - - get buffer(): AudioBuffer | null { - return this._buffer; - } - /** Takes the buffer's samples as they are now; a source takes one buffer. */ - set buffer(v: AudioBuffer | null) { - if (!v) { - this._buffer = null; - return; - } - this.native.setBuffer(v.native); - this._buffer = v; - } - - get playbackRate(): AudioParam { - return this._param('playbackRate'); - } - - get detune(): AudioParam { - return this._param('detune'); - } -} - -export class MediaElementAudioSourceNode extends AudioNode { - private _mediaElement: MediaElementLike; - - constructor(context: AudioContext, mediaElement: MediaElementLike) { - super(context, MediaElementAudioSourceNode._createNative(context, mediaElement)); - this._mediaElement = mediaElement; - markMediaElementUsed(mediaElement); - } - - /** canvas-polyfill's