骚操作之通过canvas渲染.h264视频格式数据的移动设备
效果:可以在web上操作这台设备
<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));
}
},
},
};