Skip to content

Edición de vídeo en el browser con WebGPU

Published:

Editar vídeo en el browser ha sido históricamente un caso extremo: demasiados datos, demasiadas operaciones por segundo, demasiado lento en la CPU. Con WebGPU, el panorama cambia. Procesar 30 frames por segundo a 1080p requiere mover y transformar 186 millones de píxeles por segundo. La GPU está diseñada exactamente para eso.

En este último artículo de la serie implementamos un pipeline de efectos de vídeo en tiempo real usando WebGPU. Veremos las piezas necesarias, los benchmarks y, sobre todo, las limitaciones reales que hay que tener en cuenta antes de embarcarse en esto en producción.

Stack: WebGPU + WebCodecs

Para procesar vídeo con WebGPU necesitamos dos APIs:

WebCodecs es la pieza que antes faltaba. Antes de WebCodecs, acceder a los pixels de un frame de vídeo requería dibujarlo en un canvas, leer los pixels con getImageData(), procesarlos y volver a dibujar. Lento y con mucha duplicidad de datos.

Con WebCodecs tenemos acceso directo a los frames decodificados como VideoFrame, que podemos enviar directamente a la GPU.

// Soporte requerido
if (!navigator.gpu || !window.VideoDecoder) {
  console.warn("WebGPU o WebCodecs no disponibles");
}

Arquitectura del pipeline

Archivo de vídeo

VideoDecoder (WebCodecs)

VideoFrame (datos del frame)

GPU Texture (copiamos el frame a la GPU)

Compute/Render Shader (aplicamos efectos)

Canvas (mostramos el resultado)

La clave es que el frame viaja de la memoria del vídeo a la GPU sin pasar por la CPU para el procesamiento. La CPU solo orquesta.

Implementación: efecto de escala de grises en tiempo real

Empezamos con un efecto sencillo para entender el pipeline completo.

Shader WGSL

@group(0) @binding(0) var videoTexture: texture_external;
@group(0) @binding(1) var outputTexture: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var texSampler: sampler;

@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
  let size = textureDimensions(outputTexture);
  if (id.x >= size.x || id.y >= size.y) { return; }

  let uv = vec2<f32>(f32(id.x) / f32(size.x), f32(id.y) / f32(size.y));

  // Muestreamos la textura externa (VideoFrame)
  let color = textureSampleBaseClampToEdge(videoTexture, texSampler, uv);

  // Luminancia perceptual (pesos ITU-R BT.709)
  let gray = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));

  textureStore(outputTexture, vec2<i32>(id.xy), vec4<f32>(gray, gray, gray, 1.0));
}

Pipeline de procesamiento

class VideoEffectPipeline {
  constructor(device, canvas) {
    this.device = device;
    this.canvas = canvas;
    this.context = canvas.getContext("webgpu");

    const format = navigator.gpu.getPreferredCanvasFormat();
    this.context.configure({ device, format });
  }

  async init(shaderCode) {
    const { device } = this;

    this.outputTexture = device.createTexture({
      size: [this.canvas.width, this.canvas.height],
      format: "rgba8unorm",
      usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
    });

    this.sampler = device.createSampler({
      minFilter: "linear",
      magFilter: "linear",
    });

    const shaderModule = device.createShaderModule({ code: shaderCode });

    this.pipeline = device.createComputePipeline({
      layout: "auto",
      compute: { module: shaderModule, entryPoint: "main" },
    });
  }

  processFrame(videoFrame) {
    const { device } = this;

    // Importar VideoFrame como textura GPU (zero-copy cuando es posible)
    const videoTexture = device.importExternalTexture({ source: videoFrame });

    const bindGroup = device.createBindGroup({
      layout: this.pipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: videoTexture },
        { binding: 1, resource: this.outputTexture.createView() },
        { binding: 2, resource: this.sampler },
      ],
    });

    const encoder = device.createCommandEncoder();
    const pass = encoder.beginComputePass();
    pass.setPipeline(this.pipeline);
    pass.setBindGroup(0, bindGroup);
    pass.dispatchWorkgroups(
      Math.ceil(this.canvas.width / 16),
      Math.ceil(this.canvas.height / 16)
    );
    pass.end();

    // Render pass para mostrar en canvas
    const renderPass = encoder.beginRenderPass({
      colorAttachments: [
        {
          view: this.context.getCurrentTexture().createView(),
          loadOp: "clear",
          storeOp: "store",
        },
      ],
    });
    // ... blit de outputTexture al canvas
    renderPass.end();

    device.queue.submit([encoder.finish()]);
    videoFrame.close(); // Liberar memoria del frame
  }
}

Loop de reproducción

async function startVideoProcessing(videoFile, canvasElement) {
  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();

  const pipeline = new VideoEffectPipeline(device, canvasElement);
  await pipeline.init(GRAYSCALE_SHADER);

  const decoder = new VideoDecoder({
    output: frame => {
      pipeline.processFrame(frame);
    },
    error: e => console.error("Decode error:", e),
  });

  decoder.configure({
    codec: "avc1.42001f", // H.264 baseline
    hardwareAcceleration: "prefer-hardware",
  });

  // Leer chunks del archivo y enviarlos al decoder
  const reader = videoFile.stream().getReader();
  // ... envío de chunks al decoder
}

Benchmarks: CPU vs WebGPU para efectos de vídeo

Aplicando efecto de escala de grises a vídeo 1080p/30fps:

MétodoTiempo por frameFPS sosteniblesUso de CPU
Canvas 2D (getImageData)~45ms~8 fps85%
WebGL (fragment shader)~4ms~60 fps15%
WebGPU (compute shader)~2ms~60 fps8%

El beneficio de WebGPU frente a WebGL no es solo velocidad: la CPU queda más libre para otras tareas (audio, UI, lógica de aplicación).

Para efectos más complejos (corrección de color con LUT, efectos de partículas, composición multicapa), la diferencia entre WebGL y WebGPU se amplía.

Efectos que podemos aplicar con este patrón

El patrón es siempre el mismo: un shader que recibe el frame y escribe el resultado. Algunos efectos que se pueden implementar:

Corrección de color

// Curvas de color con LUT (Look-Up Table)
let lut = textureSample(lutTexture, lutSampler, color.rgb);
textureStore(output, coords, vec4<f32>(lut.rgb, color.a));

Chroma key (green screen)

let green = vec3<f32>(0.0, 1.0, 0.0);
let diff = distance(color.rgb, green);
let alpha = select(0.0, 1.0, diff > 0.3);
textureStore(output, coords, vec4<f32>(color.rgb, alpha));

Motion blur temporal

Promedio de N frames anteriores, lo que requiere mantener un buffer de frames en GPU.

Limitaciones reales

WebCodecs y cross-origin

Los VideoFrame importados desde vídeos cross-origin tienen restricciones. En producción, los vídeos deben estar en el mismo origen o con CORS configurado.

Codecs soportados

H.264 y VP8/VP9 tienen soporte amplio. AV1 varía por dispositivo y sistema operativo. Podemos consultar VideoDecoder.isConfigSupported() antes de configurar.

const support = await VideoDecoder.isConfigSupported({
  codec: "av01.0.04M.08",
  hardwareAcceleration: "prefer-hardware",
});

if (!support.supported) {
  // Fallback a H.264
}

Sincronización de audio

WebGPU procesa vídeo sin saber nada del audio. La sincronización audio-vídeo es responsabilidad nuestra. Hay que medir el tiempo de procesamiento de cada frame y ajustar el timing de presentación.

Soporte de navegadores

WebCodecs y WebGPU están disponibles en Chrome y Edge. Safari tiene soporte parcial. Firefox no tiene ninguno de los dos en versiones estables.

Memoria GPU

Vídeos largos o múltiples streams simultáneos pueden agotar la memoria de la GPU. Hay que liberar los VideoFrame con frame.close() tan pronto como sea posible.

¿Cuándo tiene sentido en producción?

La combinación WebGPU + WebCodecs es madura para:

No está lista para:

Conclusión

El procesamiento de vídeo en el browser con WebGPU + WebCodecs es real y funciona. Con 2ms por frame en 1080p, hay margen para efectos complejos dentro del presupuesto de 16ms de un frame a 60fps.

El ecosistema todavía está madurando: soporte limitado en Firefox y Safari, APIs que cambian, poca documentación. Pero las bases están asentadas y los casos de uso son claros.

Con esto cerramos la serie. Hemos recorrido WebGPU desde los fundamentos hasta tres casos de uso reales: procesamiento de imágenes, ML en el browser y edición de vídeo. En todos, el patrón es el mismo: mover el trabajo pesado a la GPU y dejar que la CPU orqueste.


Next Post
Automatiza el navegador desde la terminal con Chrome DevTools MCP CLI