使用 Canvas 替代 <video> 标签加载并渲染视频

在部分浏览器环境或业务场景下,直接使用 <video> 标签加载视频会出现首帧延迟的情况。

以下方法通过 WebGPU + Canvas 2D 将视频帧绘制到自定义 Canvas 上,让 <video> 只做解码,WebGPU 接管渲染,通过最小化对象创建 + 精准帧回调,实现高性能、可扩展、跨端一致的视频播放管线。

HTML 部分

html 复制代码
<video id="instructional_video_id_2" :src="instru_video_src" autoplay loop muted playsinline
    style="display: none;"></video>
<canvas id="instructional_video_id_1" width="640" height="360"
    style="width: 32.3125rem; height: 18.25rem;"></canvas>

JS 代码

javascript 复制代码
import {WebGLVideoRenderer} from './video-canvas.js';

const appInstance = createApp({
    data() {
        return {
            videoElement: null,
            isVideoLoading: false,
            lastVideoUrl: null,
            isRendering: false,
            renderer: null,
        }
    },
    mounted() {
        this.initRender()
    },
    methods: {
        initRender() {
            const canvas = document.getElementById('instructional_video_id_1');
            this.renderer = new WebGLVideoRenderer(canvas);
            this.videoElement = document.getElementById('instructional_video_id_2');
            if (!this.isVideoLoading) {
                this.isVideoLoading = true
                this.videoElement.addEventListener('play', () => {
                    // 视频播放时开始绘制到 canvas
                    this.drawVideoFrame();
                });
                this.videoElement.addEventListener('pause', () => {
                    this.stopRendering();
                });
                this.videoElement.addEventListener('ended', () => {
                    this.stopRendering();
                    // 视频播放结束时重新播放
                    // this.videoElement.currentTime = 0;
                    // this.videoElement.play();
                });
                this.videoElement.addEventListener('error', () => {
                    console.error('视频加载失败');
                });
            }
        },
        // 初始化视频
        initVideo(src) {
            if (this.lastVideoUrl === src) {
                return
            }
            this.lastVideoUrl = src
            if (src === null) {
                return
            }
            // 设置视频源
            this.setVideoSource(src);
        },

        // 渲染单帧
        renderFrame() {
            // 直接调用 WebGL 渲染器
            this.renderer.render(this.videoElement);
        },
        // 绘制视频帧到 canvas
        drawVideoFrame() {
            if (this.isRendering) return;

            this.isRendering = true;
            const useRVFC = 'requestVideoFrameCallback' in this.videoElement;
            if (useRVFC) {
                const rvfcLoop = () => {
                    if (!this.isRendering) return;
                    this.renderFrame();
                    this.videoElement.requestVideoFrameCallback(rvfcLoop);
                };
                this.videoElement.requestVideoFrameCallback(rvfcLoop);
            } else {
                const renderLoop = () => {
                    if (!this.isRendering) return;
                    if (this.videoElement && !this.videoElement.paused && !this.videoElement.ended) {
                        this.renderFrame()
                    }
                    requestAnimationFrame(renderLoop);
                };
                requestAnimationFrame(renderLoop);
            }
        },
        // 停止渲染
        stopRendering() {
            this.isRendering = false;
        },
        // 设置视频源
        setVideoSource(src) {
            this.videoElement.src = src;
            this.videoElement.load();
            // this.videoElement.play();
        },
    }

video-canvas.js代码

javascript 复制代码
// video-canvas.js
export class WebGLVideoRenderer {
  constructor(canvas) {
    this.canvas = canvas;
    this.device = null;
    this.pipeline = null;
    this.sampler = null;
    this.bindGroupLayout = null;
    this.context = null;

    // 新增:可复用的对象
    this.currentExternalTexture = null;
    this.currentBindGroup = null;
    this.renderPassDescriptor = null;

    this.init();
  }

  async init() {
    if (!navigator.gpu) throw new Error('WebGPU not supported');

    const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
    this.device = await adapter.requestDevice();
    this.context = this.canvas.getContext('webgpu');

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

    // 着色器不变
    const code = `
      @vertex fn vs(@builtin(vertex_index) i: u32) ->
         @builtin(position) vec4f {
        const pos = array(vec2f(-1, -3), vec2f(3, 1), vec2f(-1, 1));
        return vec4f(pos[i], 0, 1);
      }

      @group(0) @binding(0) var s: sampler;
      @group(0) @binding(1) var t: texture_external;

      @fragment fn fs(@builtin(position) p: vec4f) ->
         @location(0) vec4f {
        let uv = p.xy / vec2f(textureDimensions(t));
        return textureSampleBaseClampToEdge(t, s, uv);
      }
    `;
    const shader = this.device.createShaderModule({ code });

    this.bindGroupLayout = this.device.createBindGroupLayout({
      entries: [
        { binding: 0, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
        { binding: 1, visibility: GPUShaderStage.FRAGMENT, externalTexture: {} }
      ]
    });

    this.pipeline = this.device.createRenderPipeline({
      layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
      vertex: { module: shader, entryPoint: 'vs' },
      fragment: { module: shader, entryPoint: 'fs', targets: [{ format }] },
      primitive: { topology: 'triangle-list' }
    });

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

    // RenderPassDescriptor 的骨架,view 每帧再填
    this.renderPassDescriptor = {
      colorAttachments: [{
        view: undefined,            // 占位,下面会替换
        loadOp: 'clear',
        storeOp: 'store'
      }]
    };
  }

  render(video) {
    if (!this.device) return;

    // 1. 画布尺寸变化时再改
    const { videoWidth, videoHeight } = video;
    if (this.canvas.width !== videoWidth || this.canvas.height !== videoHeight) {
      this.canvas.width  = videoWidth;
      this.canvas.height = videoHeight;
    }

    // 2. 只有在必要时才重新生成 BindGroup
    //    importExternalTexture 每次都会返回新对象,必须每帧调用
    const externalTexture = this.device.importExternalTexture({ source: video });
    if (this.currentExternalTexture !== externalTexture) {
      this.currentExternalTexture = externalTexture;
      this.currentBindGroup = this.device.createBindGroup({
        layout: this.bindGroupLayout,
        entries: [
          { binding: 0, resource: this.sampler },
          { binding: 1, resource: externalTexture }
        ]
      });
    }

    // 3. 更新 colorAttachment.view
    this.renderPassDescriptor.colorAttachments[0].view =
      this.context.getCurrentTexture().createView();

    // 4. 复用 RenderPassDescriptor,不再每帧 new
    const encoder = this.device.createCommandEncoder();
    const pass = encoder.beginRenderPass(this.renderPassDescriptor);
    pass.setPipeline(this.pipeline);
    pass.setBindGroup(0, this.currentBindGroup);
    pass.draw(3);
    pass.end();
    this.device.queue.submit([encoder.finish()]);
  }

  dispose() {
    this.device?.destroy();
  }
}

关键点

  • <video> 元素仅作解码器,不可见 (display: none)。

  • 每帧通过 requestVideoFrameCallback(优先)或 requestAnimationFrame 轮询,把最新纹理塞进 WebGPU。

  • Canvas 尺寸动态跟随 video.videoWidth / videoHeight,防止花屏。

相关推荐
好好研究2 小时前
使用JavaScript实现轮播图的自动切换和左右箭头切换效果
开发语言·前端·javascript·css·html
伍哥的传说6 小时前
Radash.js 现代化JavaScript实用工具库详解 – 轻量级Lodash替代方案
开发语言·javascript·ecmascript·tree-shaking·radash.js·debounce·throttle
程序视点6 小时前
IObit Uninstaller Pro专业卸载,免激活版本,卸载清理注册表,彻底告别软件残留
前端·windows·后端
前端程序媛-Tian6 小时前
【dropdown组件填坑指南】—怎么实现下拉框的位置计算
前端·javascript·vue
iamlujingtao7 小时前
js多边形算法:获取多边形中心点,且必定在多边形内部
javascript·算法
嘉琪0017 小时前
实现视频实时马赛克
linux·前端·javascript
烛阴7 小时前
Smoothstep
前端·webgl
若梦plus7 小时前
Eslint中微内核&插件化思想的应用
前端·eslint
爱分享的程序员7 小时前
前端面试专栏-前沿技术:30.跨端开发技术(React Native、Flutter)
前端·javascript·面试
超级土豆粉7 小时前
Taro 位置相关 API 介绍
前端·javascript·react.js·taro