接收视频流数据用canvas渲染

128 阅读7分钟

骚操作之通过canvas渲染.h264视频格式数据的移动设备

效果:可以在web上操作这台设备

image.png

<template>
  <div>
    <!-- canvas外层的div标签上包含移动设备的几种触屏操作方法 -->
    <div
      v-loading="loading"
      ref="emulator"
      class="emulator-container"
      :style="{ width, height }"
      ondragstart="return false;"
      oncontextmenu="return false;"
      tabindex="0"
      @mousemove="onMouseMove"
      @mousedown="onMouseDown"
      @mouseup="onMouseUp"
      @keydown="onKeyDown"
      @keyup="onKeyUp"
    >
      <canvas
        v-show="isShowCanvas"
        id="myCanvas"
        ref="videoCanvas"
        width="367"
        height="650"
        ondragstart="return false;"
        oncontextmenu="return false;"
      ></canvas>
    </div>
    <!-- 这里是我此项目的移动设备下方的三个操作按钮 -->
    <div class="control-buttons">
      <div class="back" @click="handleControlClick('back')" />
      <div class="home" @click="handleControlClick('home')" />
      <div class="menu" @click="handleControlClick('menu')" />
    </div>
  </div>
</template>

好,此时我们已经建立起来了一个手机基本框架

// 根据自身项目引入api方法
import { ActionType, KeyCode, MessageType } from "./constants.js";

export default {
  name: "EmulatorInstance",
  // 接收父组件的几个参数 首先就是websocket的通讯地址url width和height自己在data里拟定
  props: {
    url: {
      type: String,
      default: "",
    },

    height: {
      type: String,
      default: "425px",
    },

    width: {
      type: String,
      default: "240px",
    },
  },

  data() {
    return {
      loading: true,
      isConnected: false,
      ws: null, // WebSocket实例
      reconnectAttempts: 0,
      MAX_RETRIES: 10,
      // VideoDecoder和渲染相关变量
      spsData: null, // 保存SPS数据
      getFirstKeyFrame: false, // 标记是否已经收到第一个关键帧
      isFlushed: false, // 用来标识是否已经清空了解码队列
      MAX_DECODE_QUEUE_SIZE: 100, // 解码队列的最大大小
      webcodecHandle: null, // VideoDecoder实例
      offscreen: null, // 用来绘制的canvas
      canvasWidth: 367, // 画布宽度
      canvasHeight: 650, // 画布高度
      hardwareAcce: true, // 是否启用硬件加速
      isDecoderReady: false, // 解码器是否已准备好
      isMyMouseDown: false,
      isShowCanvas: false,
    };
  },

  mounted() {
    this.emulatorDom = this.$refs["emulator"];
    // 初始化canvas和websocket
    this.initCanvas();
    this.setupWebSocket();
  },
  methods: {
    // 初始化WebSocket
    setupWebSocket() {
      // 通讯地址 记得ws://拼接(http下使用ws,https下使用wss)
      // 遇到的问题 new VideoDecoder 在 localhost 可以正常初始化,在部署后的测试环境 http 下无法初始化,初步断定是 http 是非安全环境(必须https)
      // 解决方法,在本地起一个nginx服务,例如监听81端口,代理到后端地址例如192.168.10.7:5000,此时访问localhost:81,,可以正常初始化 VideoDecoder 解码器
      this.ws = new WebSocket(`ws://${this.url}`);
      this.ws.binaryType = "arraybuffer"; // 通讯的数据类型
      this.ws.onopen = () => {
        console.log("WebSocket连接成功");
        // 这里$emit是为了连接成功之后打开父组件的某些方法,不需要就去除
        this.$emit("connect-success");  
        this.isConnected = true;
        this.reconnectAttempts = 0;
      };
      this.ws.onmessage = (event) => {
        // 将接收到的event.data数据转为类型化数组
        const byteArray = new Uint8Array(event.data);
        // 视频流的数据格式正确的是[0,0,0,X,X,X] 前三位都为0,第四位开始为数字且>0
        this.webcodecDecoderAndRenderer(byteArray.slice(1)); // 处理视频数据
        // 此方法一开始可以默认除去 看渲染是否正常,如果不正常,通过次方法模拟一下点击 mousedown和mouseup
        if (!this.isMyMouseDown) {
          this.isMyMouseDown = true;
          // this.myMouseDown(); // 查看第一次渲染是否成功 如果成功这里不需要触发手动模拟鼠标点击
        }
      };
      this.ws.onerror = (err) => {
        this.$emit("connect-error");
        this.isConnected = false;
        console.error("WebSocket 错误", err);
      };
      // 断开重连
      this.ws.onclose = (evt) => {
        if (evt.code !== 1000 && this.reconnectAttempts < this.MAX_RETRIES) {
          this.reconnectAttempts++;
          setTimeout(() => {
            this.setupWebSocket(); // 重新连接WebSocket
          }, 2000);
        }
      };
    },
    // 初始化 canvas 画布
    initCanvas() {
      this.offscreen = this.$refs.videoCanvas;
      this.initWebcodec();
    },

    // 初始化 WebCodec 解码器
    initWebcodec() {
      this.webcodecHandle = new VideoDecoder({
        output: (frame) => {
          if (!this.isDecoderReady) {
            console.log("解码器未准备好,跳过渲染");
            return; // 解码器未准备好,跳过渲染
          }
          if (this.isFlushed) {
            frame.close();
            return;
          }
          // 在canvas上渲染解码后的帧
          this.offscreen
            .getContext("2d")
            .drawImage(
              frame,
              0,
              0,
              frame.displayWidth,
              frame.displayHeight,
              0,
              0,
              this.canvasWidth,
              this.canvasHeight
            );
          frame.close();
        },
        error: (e) => console.warn(e.message),
      });

      // 配置解码器
      const config = {
        codec: "avc1.42c028", // H.264 解码器
        hardwareAcceleration: "prefer-hardware",
      };

      try {
        this.webcodecHandle.configure(config);
        console.log("解码器配置成功");
        this.isDecoderReady = true; // 配置完成后标记为准备好
      } catch (error) {
        console.error("解码器配置失败", error);
      }
    },

    // 处理并解码收到的视频数据
    webcodecDecoderAndRenderer(data) {
      if (this.webcodecHandle.state === "unconfigured") {
        console.log("解码器未配置,无法解码");
        return;
      }

      // 等待清空队列
      if (this.isFlushed) {
        return;
      }

      if (this.webcodecHandle.decodeQueueSize > this.MAX_DECODE_QUEUE_SIZE) {
        // 队列过大,执行 flush 操作
        this.isFlushed = true;
        this.webcodecHandle.flush().then(() => {
          console.log("队列已清空,丢弃后续帧");
          this.isFlushed = false;
        });
        return;
      }

      // 处理接收到的 H.264 数据
      let chunk = null;
      // 在这里可以打印数据格式,查看是否正确是否可以进入满足if条件
      if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 1) {
        const nalUnitType = data[4] & 0x1f;
        // 是否已经收到第一个关键帧
        if (this.getFirstKeyFrame) {
          /* 备注:
              在 H.264/H.265 编码中,视频数据由许多 NAL单元(Network Abstraction Layer Units)组成。
              每个 NAL 单元都有一个固定的头部,描述了该单元的类型和其他属性。
              nalUnitType 的不同值所对应的类型
              1 --> 非关键帧(P 帧或 B 帧)
              5 --> 关键帧(I 帧)
              6 --> 补充增强信息(SEI)
              7 --> 序列参数集(SPS)
              8 --> 图像参数集(PPS)
          */
          // 创建视频解码基本单元
          chunk = new EncodedVideoChunk({
            timestamp: (performance.now() + performance.timeOrigin) * 1000,
            type: nalUnitType === 5 ? "key" : "delta",  // 判断是关键帧还是非关键帧
            data: data,
          });
        } else if (nalUnitType === 7 && this.spsData === null) {
          this.spsData = data.slice(); // 保存SPS数据
        } else if (nalUnitType === 5) {
          let videoChunk = new Uint8Array(this.spsData.length + data.length);
          videoChunk.set(this.spsData, 0);
          videoChunk.set(data, this.spsData.length);
          chunk = new EncodedVideoChunk({
            timestamp: (performance.now() + performance.timeOrigin) * 1000,
            type: "key",
            data: videoChunk,
          });
          this.getFirstKeyFrame = true;
        }
      }

      if (chunk) {
        try {
          this.webcodecHandle.decode(chunk);
        } catch (err) {
          console.error("解码错误", err);
        }
      }
    },
    /* 根据项目使用场景选择是或否需要模拟鼠标点击事件(点击的位置是canvas底部中间的home按钮,注意是canvas里的,不是自己div里的home)不需要可以去除 */
    // 模拟鼠标按下事件  这里的鼠标按下事件,只能用于此项目案例渲染手机设备的方法 还要根据按钮所在位置不同改变x、y轴坐标
    myMouseDown(event) {
      // 获取canvas元素
      const canvas = document.getElementById("myCanvas");
      const canvasRect = canvas.getBoundingClientRect(); // 获取canvas的边界信息

      // 计算点击位置
      const clickX = canvasRect.left + canvas.width / 2; // canvas中间的X坐标
      const clickY = canvasRect.top + canvas.height - 10; // 距离canvas底部10px的Y坐标

      // 创建一个模拟的 mousedown 事件
      const simulatedEvent = new MouseEvent("mousedown", {
        bubbles: true, // 是否冒泡
        cancelable: true, // 是否可以取消
        clientX: clickX, // 计算的鼠标相对于视口的横坐标
        clientY: clickY, // 计算的鼠标相对于视口的纵坐标
        button: 0, // 按下的鼠标按钮:0 - 左键
        buttons: 1, // 当前按下的按钮:1 - 左键
        ctrlKey: false, // 是否按住了Ctrl键
        altKey: false, // 是否按住了Alt键
        shiftKey: false, // 是否按住了Shift键
        metaKey: false, // 是否按住了Meta键
        layerX: canvas.width / 2, // 鼠标指针相对于目标元素的横坐标
        layerY: canvas.height - 10, // 鼠标指针相对于目标元素的纵坐标
        offsetX: canvas.width / 2, // 鼠标指针相对于目标元素的横坐标(包括滚动偏移)
        offsetY: canvas.height - 10, // 鼠标指针相对于目标元素的纵坐标(包括滚动偏移)
        pageX: clickX, // 鼠标指针相对于文档的横坐标
        pageY: clickY, // 鼠标指针相对于文档的纵坐标
        screenX: clickX, // 鼠标指针相对于屏幕的横坐标
        screenY: clickY, // 鼠标指针相对于屏幕的纵坐标
        timeStamp: Date.now(), // 时间戳
        which: 1, // 鼠标按钮:1 - 左键
        target: canvas, // 目标是canvas元素
      });

      // 触发事件
      canvas.dispatchEvent(simulatedEvent);
      // 模拟 mousedown 完了立刻 mouseup
      setTimeout(() => {
        this.myMouseUp();
      }, 300);
    },
    /* 根据使用场景判断是否需要模拟鼠标点击事件 */
    // 模拟鼠按下后的抬起事件
    myMouseUp(event) {
      // 获取canvas元素
      const canvas = document.getElementById("myCanvas");
      const canvasRect = canvas.getBoundingClientRect(); // 获取canvas的边界信息

      // 计算点击位置
      const clickX = canvasRect.left + canvas.width / 2; // canvas中间的X坐标
      const clickY = canvasRect.top + canvas.height - 10; // 距离canvas底部10px的Y坐标

      // 创建并触发一个模拟的 mouseup 事件
      const simulatedMouseUpEvent = new MouseEvent("mouseup", {
        bubbles: true, // 是否冒泡
        cancelable: true, // 是否可以取消
        clientX: clickX, // 鼠标相对于视口的横坐标
        clientY: clickY, // 鼠标相对于视口的纵坐标
        button: 0, // 鼠标按钮:0 - 左键
        buttons: 0, // 当前按下的按钮:0 - 没有按下按钮
        ctrlKey: false, // 是否按住了Ctrl键
        altKey: false, // 是否按住了Alt键
        shiftKey: false, // 是否按住了Shift键
        metaKey: false, // 是否按住了Meta键
        layerX: canvas.width / 2, // 鼠标指针相对于目标元素的横坐标
        layerY: canvas.height - 10, // 鼠标指针相对于目标元素的纵坐标
        offsetX: canvas.width / 2, // 鼠标指针相对于目标元素的横坐标(包括滚动偏移)
        offsetY: canvas.height - 10, // 鼠标指针相对于目标元素的纵坐标(包括滚动偏移)
        pageX: clickX, // 鼠标指针相对于文档的横坐标
        pageY: clickY, // 鼠标指针相对于文档的纵坐标
        screenX: clickX, // 鼠标指针相对于屏幕的横坐标
        screenY: clickY, // 鼠标指针相对于屏幕的纵坐标
        timeStamp: Date.now(), // 时间戳
        which: 1, // 鼠标按钮:1 - 左键
        target: canvas, // 目标是canvas元素
      });

      // 触发 mouseup 事件
      canvas.dispatchEvent(simulatedMouseUpEvent);
      // 防止绿屏,mouseup执行完 1000ms 后再展示canvas
      setTimeout(() => {
        this.isShowCanvas = true;
        this.loading = false;
      }, 8000); // 这里设置了延时8s,是为了等待websocket的onmessage接收数据变化完了后,再展示canvas
    },
    // 发送鼠标操作
    sendMouseEventMessage(event, action) {
      // event 鼠标事件对象  action 鼠标操作类型(mousemove、mousedown...)
      const { offsetX, offsetY } = event;
      
      // 当前鼠标所在位置相对于父元素的宽高比
      const { offsetWidth, offsetHeight } = this.emulatorDom;
      const parentWidthRatio = offsetX / offsetWidth;
      const parentHeightRatio = offsetY / offsetHeight;
      // 定义鼠标动作映射表
      const actionMap = {
        mousemove: ActionType.MOUSE_MOVE,
        mousedown: ActionType.MOUSE_DOWN,
        mouseup: ActionType.MOUSE_UP,
      };
      // 构造消息内容
      const message = {
        type: MessageType.DO_TOUCH,
        message: window.btoa(
          JSON.stringify({
            x: parentWidthRatio,
            y: parentHeightRatio,
            type: actionMap[action],
          })
        ),
      };

      this.ws.send(JSON.stringify(message));
    },
    
    onMouseMove(event) {
      if (this.isConnected) {
        this.sendMouseEventMessage(event, "mousemove");
      }
    },

    onMouseDown(event) {
      if (this.isConnected) {
        this.sendMouseEventMessage(event, "mousedown");
      }
    },

    onMouseUp(event) {
      if (this.isConnected) {
        this.sendMouseEventMessage(event, "mouseup");
      }
    },
    // 发送键盘消息
    sendKeyboardEventMessage(event, action) {
      const actionMap = {
        keyup: ActionType.MOUSE_UP,
        keydown: ActionType.MOUSE_DOWN,
      };

      const message = {
        type: MessageType.DO_KEYBOARD,
        message: window.btoa(
          JSON.stringify({
            keyCode: event.keyCode,
            shiftKey: event.shiftKey,
            ctrlKey: event.ctrlKey,
            altKey: event.altKey,
            metaKey: event.metaKey,
            symKey: false,
            functionKey: false,
            capsLockKey: false,
            scrollLockKey: false,
            numLockKey: false,
            type: actionMap[action],
          })
        ),
      };

      this.ws.send(JSON.stringify(message));
    },

    onKeyDown(event) {
      if (this.isConnected) {
        this.sendKeyboardEventMessage(event, "keydown");
      }
    },

    onKeyUp(event) {
      if (this.isConnected) {
        this.sendKeyboardEventMessage(event, "keyup");
      }
    },
    // 操作手机底部bottom按钮
    handleControlClick(type) {
      if (this.isConnected) {
        const map = {
          back: KeyCode.KEYCODE_BACK,
          home: KeyCode.KEYCODE_HOME,
          menu: KeyCode.KEYCODE_APP_SWITCH,
        };

        const message = {
          type: MessageType.DO_FUNCTIONAL,
          message: window.btoa(
            JSON.stringify({
              func: map[type],
            })
          ),
        };
        this.ws.send(JSON.stringify(message));
      }
    },
  },
};