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

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

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

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 代码

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代码

// 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,防止花屏。

<div class="content"> <!-- 左侧SFC列表 --> <div class="sfc-list"> <div v-for="sfc in sfcList" :key="sfc.sfcNo" :class="['sfc-item', { active: activeSfc?.sfcNo === sfc.sfcNo }]" @click="selectSfc(sfc)"> <div class="sfc-header"> <span class="sfc-no" :class="statusClass(sfc.status)">{{ sfc.sfcNo }}</span> </div> <div class="sfc-meta"> <span>{{ sfc.keyInfo.length }} 工位</span> <span>{{ getSfcCompletedCount(sfc) }}/{{ getSfcTotalCount(sfc) }}</span> <el-tag v-if="getSfcCompletedCount(sfc) === 0" size="small" type="info"> 未开始 </el-tag> <el-tag v-if="getSfcCompletedCount(sfc) < getSfcTotalCount(sfc) && getSfcCompletedCount(sfc) !== 0" size="small" class="custom-light-blue"> 进行中 </el-tag> <el-tag v-if="getSfcCompletedCount(sfc) === getSfcTotalCount(sfc)" size="small" type="success"> 已完成 </el-tag> </div> </div> </div> <!-- 中间工位列表 --> <div class="station-list" v-if="activeSfc"> <div v-for="station in activeSfc.keyInfo" :key="station.station" :class="['station-item', { active: activeStation?.station === station.station }]" @click="selectStation(station)"> <div class="station-header"> <span class="station-no">{{ station.station }}</span> <el-progress :percentage="getStationProgress(station)" :stroke-width="12" :show-text="false" :color="getStationProgress(station) === 100 ? '#7fb897' : undefined" /> </div> <div class="station-meta"> <span>{{ station.keys.length }} 物料</span> <el-tag size="small"> {{ getCompletedCount(station) }}/{{ station.keys.length }} 完成 </el-tag> </div> </div> </div> <!-- 右侧工艺流 --> <div class="process-flow" v-if="activeMaterial"> <div class="process-header"> <h3>关键零部件检测</h3> </div> <div class="vertical-process"> <!-- 纵向步骤条 --> <el-steps direction="vertical" :active="currentStep" finish-status="success"> <!-- 第一步:拍照 --> <el-step title="拍照" > <template #description> <div v-if="currentStep === 1" class="step-content"> <div class="step-section"> <div class="camera-area"> <div class="camera-preview"> <img v-if="activeMaterial.imageUrl" :src="activeMaterial.imageUrl" alt="物料照片" class="preview-image" /> <div v-else class="camera-placeholder"> <el-icon :size="60"><Camera /></el-icon> <p>点击下方按钮进行拍照</p> </div> </div> <div class="camera-controls"> <!-- 打开摄像头按钮 --> <el-button type="primary" @click="openCamera"> <el-icon><VideoCamera /></el-icon> 打开摄像头 </el-button> <input ref="fileInput" type="file" accept="image/*" @change="uploadImage" style="position:absolute;visibility:hidden"> <el-button @click="fileInput?.click()" > <el-icon><Upload /></el-icon> 上传图片 </el-button> <!-- <el-button @click="uploadImage"> <el-icon><Upload /></el-icon> 上传图片 </el-button> --> <el-button type="primary" @click="nextStep" :disabled="!activeMaterial.imageUrl"> 下一步 </el-button> </div> </div> </div> </div> <!-- 摄像头浮层 --> <div v-if="cameraVisible" class="camera-overlay"> <div class="camera-container"> <div class="camera-header"> <h3>拍照</h3> <el-button icon="Close" circle @click="closeCamera" /> </div> <div class="camera-view"> <video ref="videoRef" autoplay playsinline class="camera-feed"></video> <canvas ref="canvasRef" class="camera-canvas" style="display: none;"></canvas> </div> <div class="camera-actions"> <el-button type="primary" @click="capturePhoto"> <el-icon><Camera /></el-icon> 拍照 </el-button> <el-button @click="retakePhoto"> <el-icon><Refresh /></el-icon> 重拍 </el-button> </div> </div> </div> </template> </el-step> <!-- 第二步:检测 --> <el-step title="检测" > <template #description> <div v-if="currentStep === 2" class="step-content"> <div class="step-section"> <div class="ocr-detection"> <div class="ocr-controls"> <el-form label-width="80px"> <el-form-item label="类别"> <el-select v-model="selectedTypeValue" placeholder="选择类别"> <el-option v-for="item in ocrSettings.category" :key="item.value" :label="item.label" :value="item.value"> </el-option> </el-select> </el-form-item> <el-form-item label="工位"> <el-input v-model="ocrSettings.station" placeholder="当前工位" disabled /> </el-form-item> <el-form-item label="二维码" class="custom-input"> <el-switch v-model="selectedQRValue" active-text="开启" inactive-text="关闭" /> </el-form-item> <el-form-item label="阈值"> <el-slider v-model="ocrSettings.threshold" :min="0" :max="100" :step="5" show-input /> </el-form-item> <el-form-item> <el-button type="primary" :icon="ocrSettings.active ? SwitchButton : Open" @click="toggleOcr" > {{ ocrSettings.active ? '关闭' : '启动' }} OCR </el-button> </el-form-item> <el-form-item> <el-button type="success" :icon="Promotion" @click="detection" :disabled="!ocrSettings.active" > 开始识别 </el-button> </el-form-item> </el-form> </div> <div class="ocr-preview"> <div class="image-container"> <img :src="activeMaterial.imageUrl" alt="待识别图片" class="ocr-image" /> <!-- <div v-if="ocrResult" class="ocr-result-overlay"> <div v-for="(box, idx) in ocrResult.boxes" :key="idx" class="ocr-box" :style="{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.width}%`, height: `${box.height}%` }" > <span class="ocr-label">{{ box.label }}</span> </div> </div> --> </div> <div class="ocr-actions"> <el-button type="primary" @click="prevStep">上一步</el-button> <el-button type="primary" @click="nextStep" :disabled="ocrResult.uuid == ''"> 下一步 </el-button> </div> </div> </div> </div> </div> </template> </el-step> <!-- 第三步:校验 --> <el-step title="校验" > <template #description> <div v-if="currentStep === 3" class="step-content"> <div class="step-section"> <div class="verification-area"> <div class="verification-header"> <!-- <h4>结果校验</h4> --> <el-tag type="warning">请确认以下信息</el-tag> </div> <div class="form-image-container"> <el-form label-width="80px"> <!-- <el-form-item label="物料号"> <el-input v-model="ocrResult.materialNo" placeholder="物料号" /> <div class="ocr-source"> <el-tag v-if="ocrResult.materialNo !== undefined" size="small" :type="ocrResult.materialNo ? 'success' : 'error'"> OCR-物料号: {{ ocrResult.materialNo || '空'}} </el-tag> </div> </el-form-item> <el-form-item label="生产商"> <el-input v-model="ocrResult.supplierNo" placeholder="生产商" /> <div class="ocr-source"> <el-tag v-if="ocrResult.supplierNo !== undefined" size="small" :type="ocrResult.supplierNo ? 'success' : 'error'"> OCR-生产商: {{ ocrResult.supplierNo || '空'}} </el-tag> </div> </el-form-item> --> <el-form-item label="物料号"> <el-select v-model="ocrResult.materialNo" placeholder="请选择物料号" :class="{'success-border': isMaterialValid, 'error-border': !isMaterialValid}"> <el-option v-for="part in remainingPartsVaild" :key="part" :label="part" :value="part"> </el-option> </el-select> <div class="ocr-source"> <el-tag v-if="ocrResult.materialNo !== undefined" size="small" :type="isMaterialValid(activeStation!) ? 'success' : 'error'"> OCR-物料号: {{ ocrResult.materialNo || '空' }} </el-tag> </div> </el-form-item> <el-form-item label="生产商"> <el-select v-model="ocrResult.supplierNo" placeholder="请选择生产商" :class="{'success-border': isSupplierValid, 'error-border': !isSupplierValid}"> <el-option v-for="supplier in supplierNosVaild" :key="supplier" :label="supplier" :value="supplier"> </el-option> </el-select> <div class="ocr-source"> <el-tag v-if="ocrResult.supplierNo !== undefined" size="small" :type="isSupplierValid(activeStation!) ? 'success' : 'error'"> OCR-生产商: {{ ocrResult.supplierNo || '空' }} </el-tag> </div> </el-form-item> <el-form-item label="序列号"> <el-input v-model="ocrResult.serialNo" placeholder="序列号" /> <div class="ocr-source"> <el-tag v-if="ocrResult.serialNo !== undefined" size="small" :type="ocrResult.serialNo ? 'success' : 'error'"> OCR-序列号: {{ ocrResult.serialNo || '空'}} </el-tag> </div> </el-form-item> </el-form> <div class="image-container"> <img :src="activeMaterial.imageUrl" alt="待识别图片" class="ocr-image" /> </div> </div> <div class="step-actions"> <el-button @click="prevStep">上一步</el-button> <el-button type="primary" @click="nextStep" :disabled="shouldDisableButton()" > 下一步 </el-button> </div> </div> </div> </div> </template> </el-step> <!-- 第四步:完成 --> <el-step title="完成" > <template #description> <div v-if="currentStep === 4" class="step-content"> <div class="step-section"> <div class="completion-area" > <el-result icon="success" title="流程完成"> <template #extra> <div class="summary-info"> <el-descriptions :column="1" border> <el-descriptions-item label="SFC"> {{ activeSfc?.sfcNo }} </el-descriptions-item> <el-descriptions-item label="工位"> {{ activeStation?.station }} </el-descriptions-item> <el-descriptions-item label="生产商"> <div style=" display: flex; justify-content: space-between; align-items: center; width: 100%"> <span style=" flex: 1; overflow: hidden; text-overflow: ellipsis;white-space: nowrap;padding-right: 10px"> {{ ocrResult?.supplierNo }} </span> <div style=" display: flex; gap: 0px; flex-shrink: 0"> <el-button type="primary" size="small" @click="handleQRcode(ocrResult?.supplierNo)" > 二维码 </el-button> <el-button type="primary" size="small" @click="handleBarcode(ocrResult?.supplierNo)" > 条形码 </el-button> </div> </div> </el-descriptions-item> <el-descriptions-item label="物料号"> <div style=" display: flex; justify-content: space-between; align-items: center; width: 100%"> <span style=" flex: 1; overflow: hidden; text-overflow: ellipsis;white-space: nowrap;padding-right: 10px"> {{ ocrResult?.materialNo }} </span> <div style=" display: flex; gap: 0px; flex-shrink: 0"> <el-button type="primary" size="small" @click="handleQRcode(ocrResult?.materialNo)" > 二维码 </el-button> <el-button type="primary" size="small" @click="handleBarcode(ocrResult?.materialNo)" > 条形码 </el-button> </div> </div> </el-descriptions-item> <el-descriptions-item label="序列号"> <div style=" display: flex; justify-content: space-between; align-items: center; width: 100%"> <span style=" flex: 1; overflow: hidden; text-overflow: ellipsis;white-space: nowrap;padding-right: 10px"> {{ ocrResult?.serialNo }} </span> <div style=" display: flex; gap: 0px; flex-shrink: 0"> <el-button type="primary" size="small" @click="handleQRcode(ocrResult?.serialNo)" > 二维码 </el-button> <el-button type="primary" size="small" @click="handleBarcode(ocrResult?.serialNo)" > 条形码 </el-button> </div> </div> </el-descriptions-item> <el-descriptions-item label="完成时间"> {{ dayjs(new Date()).format('YYYY-MM-DD hh:mm:ss') }} </el-descriptions-item> </el-descriptions> <!-- <div class="verification-summary"> <h4>验证信息</h4> <p>供应商号: {{ ocrResult.supplierNo }}</p> <p>物料号: {{ ocrResult.materialNo }}</p> <p>序列号: {{ ocrResult.serialNo }}</p> </div> --> <div v-if="showQR" class="modal-mask" @click.self="closeModal"> <div class="modal-container"> <qrcode-vue :value="qrText" :size="200" level="H" class="qr-code" /> <button class="close-btn" @click="closeModal">×</button> </div> </div> <div> <BarcodeGenerator ref="barcodeRef" />现在希望右侧工艺流是点击左侧中间工位后弹跳的新的页面上显示操作 </div> </div> </template> </el-result> </div> <div class="step-actions"> <el-button @click="prevStep">上一步</el-button> <el-button type="success" @click="completeProcess">确认提交</el-button> </div> </div> </div> </template> </el-step> </el-steps> </div> </div> <div v-else-if="activeStation" class="no-material"> <el-empty description="该工位没有需要处理的物料" /> </div> <div v-else-if="activeSfc" class="no-station"> <el-empty description="请选择工位开始处理" /> </div> <div v-else class="no-sfc"> <el-empty description="请选择SFC开始处理" /> </div> </div>
最新发布
09-18
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值