UNPKG

jassub

Version:

The Fastest JavaScript SSA/ASS Subtitle Renderer For Browsers

404 lines (386 loc) 15.2 kB
// WARN: // This has been deprecated as WebGL is simply faster // Know how to optimise this to beat WebGL? submit a PR! const IDENTITY_MATRIX = new Float32Array([ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0 ]); // Color matrix conversion map - mat3x3 pre-padded for WGSL (each column padded to vec4f) // Each matrix converts FROM the key color space TO the nested key color space export const colorMatrixConversionMap = { BT601: { BT709: new Float32Array([ 1.0863, 0.0965, -0.0141, 0, -0.0723, 0.8451, -0.0277, 0, -0.014, 0.0584, 1.0418, 0 ]), BT601: IDENTITY_MATRIX }, BT709: { BT601: new Float32Array([ 0.9137, -0.1049, 0.0096, 0, 0.0784, 1.1722, 0.0322, 0, 0.0079, -0.0671, 0.9582, 0 ]), BT709: IDENTITY_MATRIX }, FCC: { BT709: new Float32Array([ 1.0873, 0.0974, -0.0127, 0, -0.0736, 0.8494, -0.0251, 0, -0.0137, 0.0531, 1.0378, 0 ]), BT601: new Float32Array([ 1.001, 0.0009, 0.0013, 0, -0.0008, 1.005, 0.0027, 0, -0.0002, -0.006, 0.996, 0 ]) }, SMPTE240M: { BT709: new Float32Array([ 0.9993, -0.0004, -0.0034, 0, 0.0006, 0.9812, -0.0114, 0, 0.0001, 0.0192, 1.0148, 0 ]), BT601: new Float32Array([ 0.913, -0.1051, 0.0063, 0, 0.0774, 1.1508, 0.0207, 0, 0.0096, -0.0456, 0.973, 0 ]) } }; // WGSL Vertex Shader const VERTEX_SHADER = /* wgsl */ ` struct VertexOutput { @builtin(position) position: vec4f, @location(0) @interpolate(flat) destXY: vec2f, // destination top-left (flat, no interpolation) @location(1) @interpolate(flat) color: vec4f, @location(2) @interpolate(flat) texSize: vec2f, } struct Uniforms { resolution: vec2f, } struct ImageData { destRect: vec4f, // x, y, w, h srcInfo: vec4f, // texW, texH, stride, 0 color: vec4f, // RGBA } @group(0) @binding(0) var<uniform> uniforms: Uniforms; @group(0) @binding(1) var<storage, read> imageData: ImageData; // Quad vertices (two triangles) const QUAD_POSITIONS = array<vec2f, 6>( vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0), vec2f(0.0, 1.0) ); @vertex fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { var output: VertexOutput; let quadPos = QUAD_POSITIONS[vertexIndex]; let wh = imageData.destRect.zw; // Calculate pixel position let pixelPos = imageData.destRect.xy + quadPos * wh; // Convert to clip space (-1 to 1) var clipPos = (pixelPos / uniforms.resolution) * 2.0 - 1.0; clipPos.y = -clipPos.y; // Flip Y for canvas coordinates output.position = vec4f(clipPos, 0.0, 1.0); output.destXY = imageData.destRect.xy; output.color = imageData.color; output.texSize = imageData.srcInfo.xy; return output; } `; // WGSL Fragment Shader - use textureLoad with integer coords for pixel-perfect sampling const FRAGMENT_SHADER = /* wgsl */ ` @group(0) @binding(3) var tex: texture_2d<f32>; @group(0) @binding(4) var<uniform> colorMatrix: mat3x3f; struct FragmentInput { @builtin(position) fragCoord: vec4f, @location(0) @interpolate(flat) destXY: vec2f, @location(1) @interpolate(flat) color: vec4f, @location(2) @interpolate(flat) texSize: vec2f, } @fragment fn fragmentMain(input: FragmentInput) -> @location(0) vec4f { // Calculate integer texel coordinates from fragment position // fragCoord.xy is the pixel center (e.g., 0.5, 1.5, 2.5...) let texCoord = vec2i(floor(input.fragCoord.xy - input.destXY)); // Bounds check (should not be needed but prevents any out-of-bounds access) let texSizeI = vec2i(input.texSize); if (texCoord.x < 0 || texCoord.y < 0 || texCoord.x >= texSizeI.x || texCoord.y >= texSizeI.y) { return vec4f(0.0); } // Load texel directly using integer coordinates - no interpolation, no precision issues let mask = textureLoad(tex, texCoord, 0).r; // Apply color matrix conversion (identity if no conversion needed) let correctedColor = colorMatrix * input.color.rgb; // libass color alpha: 0 = opaque, 255 = transparent (inverted) let colorAlpha = 1.0 - input.color.a; // Final alpha = colorAlpha * mask (like libass: alpha * mask) let a = colorAlpha * mask; // Premultiplied alpha output return vec4f(correctedColor * a, a); } `; export class WebGPURenderer { device = null; context = null; pipeline = null; bindGroupLayout = null; // Uniform buffer uniformBuffer = null; // Color matrix buffer (mat3x3f = 48 bytes with padding) colorMatrixBuffer = null; // Image data buffers (created on-demand, one per image) imageDataBuffers = []; // Textures created on-demand (no fixed limit) textures = []; pendingDestroyTextures = []; // eslint-disable-next-line no-undef format = 'bgra8unorm'; constructor(device) { this.device = device; this.format = navigator.gpu.getPreferredCanvasFormat(); // Create shader modules const vertexModule = this.device.createShaderModule({ code: VERTEX_SHADER }); const fragmentModule = this.device.createShaderModule({ code: FRAGMENT_SHADER }); // Create uniform buffer this.uniformBuffer = this.device.createBuffer({ size: 16, // vec2f resolution + padding usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // Create color matrix buffer (mat3x3f requires 48 bytes: 3 vec3f padded to vec4f each) this.colorMatrixBuffer = this.device.createBuffer({ size: 48, // 3 x vec4f (each column is vec3f padded to 16 bytes) usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // Initialize with identity matrix this.device.queue.writeBuffer(this.colorMatrixBuffer, 0, IDENTITY_MATRIX); // Create bind group layout (no sampler needed - using textureLoad for pixel-perfect sampling) this.bindGroupLayout = this.device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }, { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'unfilterable-float' } // textureLoad requires unfilterable }, { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } } ] }); // Create pipeline layout const pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }); // Create render pipeline this.pipeline = this.device.createRenderPipeline({ layout: pipelineLayout, vertex: { module: vertexModule, entryPoint: 'vertexMain' }, fragment: { module: fragmentModule, entryPoint: 'fragmentMain', targets: [ { format: this.format, blend: { color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }, alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' } } } ] }, primitive: { topology: 'triangle-list' } }); } setCanvas(canvas, width, height) { if (!this.device) return; // WebGPU doesn't allow 0-sized textures/swapchains if (width <= 0 || height <= 0) return; canvas.width = width; canvas.height = height; if (!this.context) { // Get canvas context this.context = canvas.getContext('webgpu'); if (!this.context) { throw new Error('Could not get WebGPU context'); } this.context.configure({ device: this.device, format: this.format, alphaMode: 'premultiplied' }); } // Update uniform buffer with resolution this.device.queue.writeBuffer(this.uniformBuffer, 0, new Float32Array([width, height])); } /** * Set the color matrix for color space conversion. * Pass null or undefined to use identity (no conversion). * Matrix should be a pre-padded Float32Array with 12 values (3 columns × 4 floats each). */ setColorMatrix(subtitleColorSpace, videoColorSpace) { if (!this.device) return; const colorMatrix = (subtitleColorSpace && videoColorSpace && colorMatrixConversionMap[subtitleColorSpace]?.[videoColorSpace]) ?? IDENTITY_MATRIX; this.device.queue.writeBuffer(this.colorMatrixBuffer, 0, colorMatrix); } createTextureInfo(width, height) { const texture = this.device.createTexture({ size: [width, height], format: 'r8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST }); return { texture, view: texture.createView(), width, height }; } render(images, heap) { if (!this.device || !this.context || !this.pipeline) return; // getCurrentTexture fails if canvas has 0 dimensions const currentTexture = this.context.getCurrentTexture(); if (currentTexture.width === 0 || currentTexture.height === 0) return; const commandEncoder = this.device.createCommandEncoder(); const textureView = currentTexture.createView(); // Begin render pass const renderPass = commandEncoder.beginRenderPass({ colorAttachments: [ { view: textureView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: 'clear', storeOp: 'store' } ] }); renderPass.setPipeline(this.pipeline); // Grow arrays if needed while (this.textures.length < images.length) { this.textures.push(this.createTextureInfo(64, 64)); } while (this.imageDataBuffers.length < images.length) { this.imageDataBuffers.push(this.device.createBuffer({ size: 48, // 3 x vec4f usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST })); } // Render each image for (let i = 0, texIndex = -1; i < images.length; i++) { const img = images[i]; // Skip images with invalid dimensions (WebGPU doesn't allow 0-sized textures) if (img.w <= 0 || img.h <= 0) continue; let texInfo = this.textures[++texIndex]; // Recreate texture if size changed (use actual w, not stride) if (texInfo.width !== img.w || texInfo.height !== img.h) { // Defer destruction until after submit to avoid destroying textures still in use this.pendingDestroyTextures.push(texInfo.texture); texInfo = this.createTextureInfo(img.w, img.h); this.textures[texIndex] = texInfo; } // Upload bitmap data using bytesPerRow to handle stride // Only need stride * (h-1) + w bytes per ASS spec // this... didnt work, is the used alternative bad? // const dataSize = img.stride * (img.h - 1) + img.w // const bitmapData = heap.subarray(img.bitmap, img.bitmap + dataSize) // this.device.queue.writeTexture( // { texture: texInfo.texture }, // bitmapData as unknown as ArrayBuffer, // { bytesPerRow: img.stride }, // Source rows are stride bytes apart // { width: img.w, height: img.h } // But we only copy w pixels per row // ) this.device.queue.writeTexture({ texture: texInfo.texture }, heap.buffer, { bytesPerRow: img.stride, offset: img.bitmap }, // Source rows are stride bytes apart { width: img.w, height: img.h } // But we only copy w pixels per row ); // Update image data buffer const imageData = new Float32Array([ // destRect img.dst_x, img.dst_y, img.w, img.h, // srcInfo img.w, img.h, img.stride, 0, // color (RGBA from 0xRRGGBBAA) ((img.color >>> 24) & 0xFF) / 255, ((img.color >>> 16) & 0xFF) / 255, ((img.color >>> 8) & 0xFF) / 255, (img.color & 0xFF) / 255 ]); const imageBuffer = this.imageDataBuffers[texIndex]; this.device.queue.writeBuffer(imageBuffer, 0, imageData); // Create bind group for this image (no sampler - using textureLoad) const bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ { binding: 0, resource: { buffer: this.uniformBuffer } }, { binding: 1, resource: { buffer: imageBuffer } }, { binding: 3, resource: texInfo.view }, { binding: 4, resource: { buffer: this.colorMatrixBuffer } } ] }); renderPass.setBindGroup(0, bindGroup); renderPass.draw(6); // 6 vertices for quad } renderPass.end(); this.device.queue.submit([commandEncoder.finish()]); // Now safe to destroy old textures for (const tex of this.pendingDestroyTextures) { tex.destroy(); } this.pendingDestroyTextures = []; } destroy() { for (const tex of this.textures) { tex.texture.destroy(); } this.textures = []; this.uniformBuffer?.destroy(); this.colorMatrixBuffer?.destroy(); for (const buf of this.imageDataBuffers) { buf.destroy(); } this.imageDataBuffers = []; this.device?.destroy(); this.device = null; this.context = null; } } //# sourceMappingURL=webgpu-renderer.js.map