feat: 添加视频帧截取-WebAssembly版,支持MP4MOVAVIWebMMKV等主流格式

This commit is contained in:
xiaoxian521 2023-07-18 12:35:13 +08:00
parent 611b591da8
commit fe5ed68ff7
11 changed files with 17204 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
public/wasm/capture.worker.js linguist-language=Vue
public/wasm/index.js linguist-language=Vue

View File

@ -68,6 +68,7 @@ menus:
hsguide: Guide
hsAble: Able
hsMenuTree: Menu Tree
hsVideoFrame: Video Frame Capture
hsWavesurfer: Audio Visualization
hsOptimize: Debounce、Throttle、Copy、Longpress Directives
hsWatermark: Water Mark

View File

@ -68,6 +68,7 @@ menus:
hsguide: 引导页
hsAble: 功能
hsMenuTree: 菜单树结构
hsVideoFrame: 视频帧截取-wasm版
hsWavesurfer: 音频可视化
hsOptimize: 防抖、截流、复制、长按指令
hsWatermark: 水印

11349
public/wasm/capture.worker.js Normal file

File diff suppressed because it is too large Load Diff

BIN
public/wasm/capture.worker.wasm Executable file

Binary file not shown.

5477
public/wasm/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,15 @@ export default {
rank: able
},
children: [
{
path: "/able/videoFrame",
name: "VideoFrame",
component: () => import("@/views/able/video-frame/index.vue"),
meta: {
title: $t("menus.hsVideoFrame"),
extraIcon: "IF-pure-iconfont-new svg"
}
},
{
path: "/able/wavesurfer",
name: "Wavesurfer",

View File

@ -8,6 +8,13 @@ type Events = {
tagViewsShowModel: string;
logoChange: boolean;
changLayoutRoute: string;
imageInfo: {
img: HTMLImageElement;
height: number;
width: number;
x: number;
y: number;
};
};
export const emitter: Emitter<Events> = mitt<Events>();

View File

@ -0,0 +1,185 @@
// import { throttle } from "@pureadmin/utils";
import { emitter } from "@/utils/mitt";
export class CanvasRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private images: {
img: HTMLImageElement;
x: number;
y: number;
width: number;
height: number;
}[];
private container: HTMLElement;
private positionX: number;
private isDragging: boolean;
private startX: number;
constructor(containerId: string) {
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d")!;
this.images = [];
this.positionX = 0;
this.isDragging = false;
this.startX = 0;
this.container = document.getElementById(containerId);
if (this.container) {
this.container.appendChild(this.canvas);
this.canvas.width = this.container.clientWidth;
this.canvas.height = this.container.clientHeight;
}
}
public addImage(
url: string,
x: number,
y: number,
width: number,
height: number
) {
const img = new Image();
img.src = url;
this.images.push({
img,
x,
y,
width,
height
});
this.render();
}
public render() {
this.clearRect();
this.images.forEach(imgProps => {
const x = imgProps.x + this.positionX;
this.ctx.drawImage(
imgProps.img,
x,
imgProps.y,
imgProps.width,
imgProps.height
);
});
}
public clearImages() {
this.images = [];
}
public clearRect() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
public drawTick(event) {
this.render();
// 当前勾选图片的索引
const index =
Math.ceil(
(Math.abs(this.positionX) + event.offsetX) / this.images[0].width
) - 1;
const x = event.offsetX;
const y = event.offsetY;
// 绘制样式
this.ctx.strokeStyle = "red";
this.ctx.lineWidth = 4;
this.ctx.lineCap = "round";
// 绘制对勾
this.ctx.beginPath();
this.ctx.moveTo(x - 10, y);
this.ctx.lineTo(x, y + 10);
this.ctx.lineTo(x + 15, y - 10);
this.ctx.stroke();
emitter.emit("imageInfo", this.images[index]);
}
public addListener() {
if (!this.canvas) return;
this.canvas.addEventListener("click", this.handleClick);
this.canvas.addEventListener("mousedown", this.handleMouseDown);
this.canvas.addEventListener("mousemove", this.handleMouseMove);
this.canvas.addEventListener("mouseup", this.handleMouseUp);
this.canvas.addEventListener("touchstart", this.handleTouchStart);
this.canvas.addEventListener("touchmove", this.handleTouchMove);
this.canvas.addEventListener("touchend", this.handleTouchEnd);
// window.addEventListener("resize", throttle(this.handleWindowResize, 200));
}
private handleClick = (event: MouseEvent) => {
this.drawTick(event);
};
private handleMouseDown = (event: MouseEvent) => {
this.startDrag(event.clientX);
};
private handleMouseMove = (event: MouseEvent) => {
this.drag(event.clientX);
};
private handleMouseUp = () => {
this.endDrag();
};
private handleTouchStart = (event: TouchEvent) => {
if (event.touches.length === 1) {
event.preventDefault();
this.startDrag(event.touches[0].clientX);
}
};
private handleTouchMove = (event: TouchEvent) => {
if (event.touches.length === 1) {
event.preventDefault();
this.drag(event.touches[0].clientX);
}
};
private handleTouchEnd = () => {
this.endDrag();
};
private startDrag(clientX: number) {
this.canvas.style.cursor = "grabbing";
this.canvas.style.userSelect = "none";
this.startX = clientX;
this.isDragging = true;
}
private drag(clientX: number) {
if (!this.isDragging) return;
const deltaX = clientX - this.startX;
const maxPositionX =
this.images.length * this.images[0].width - this.container.clientWidth;
this.positionX = Math.max(
Math.min(this.positionX + deltaX, 0),
-maxPositionX
);
this.startX = clientX;
this.render();
}
private endDrag() {
this.canvas.style.cursor = "grab";
this.canvas.style.userSelect = "auto";
this.isDragging = false;
}
// private handleWindowResize = () => {
// this.canvas.width = this.container.clientWidth;
// this.canvas.height = this.container.clientHeight;
// this.render();
// };
}

View File

@ -0,0 +1,172 @@
<script setup lang="ts">
import { emitter } from "@/utils/mitt";
import { useLoader } from "@pureadmin/utils";
import { CanvasRenderer } from "./canvasRenderer";
import { ref, onMounted, onBeforeUnmount } from "vue";
defineOptions({
name: "VideoFrame"
});
const num = 200;
const curImg = ref("");
const renderer = ref();
const captureUtil = ref();
const loading = ref(false);
const { loadScript } = useLoader();
const { VITE_PUBLIC_PATH } = import.meta.env;
const getPath = path => `${VITE_PUBLIC_PATH}wasm/${path}`;
const src = getPath("index.js");
const workerPath = getPath("capture.worker.js");
const wasmPath = getPath("capture.worker.wasm");
loadScript({
src
}).then(mgs => {
if (mgs === "success") {
// @ts-expect-error
captureUtil.value = cheetahCapture.initCapture({
workerPath,
wasmPath
});
}
});
onMounted(() => {
renderer.value = new CanvasRenderer("canvas-container");
emitter.on("imageInfo", info => (curImg.value = info.img.src));
});
function beforeUpload(file) {
curImg.value = "";
loading.value = true;
renderer.value.clearImages();
// api https://github.com/wanwu/cheetah-capture#api
captureUtil.value.then(res => {
res.capture({
//
file,
// `number`https://github.com/wanwu/cheetah-capture/issues/6#issuecomment-1634384486
info: 16,
//
onChange: (list, { url }) => {
renderer.value.addImage(url, num * list.url.length, 0, num, num);
},
//
onSuccess: () => {
renderer.value.addListener();
//
renderer.value.drawTick({ offsetX: num / 2, offsetY: num / 2 });
loading.value = false;
},
//
onError: () => {
loading.value = false;
}
});
});
return false;
}
onBeforeUnmount(() => {
// `imageInfo`
emitter.off("imageInfo");
});
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-medium">
<p>
基于自定义编译
<el-link
href="https://github.com/FFmpeg/FFmpeg"
target="_blank"
style="margin: 0 4px 5px; font-size: 16px"
>
FFmpeg
</el-link>
的截帧工具支持MP4MOVAVIWebMMKV等主流格式支持
H.264AVCH.265HEVCMPEG-2MPEG-4VP8VP9WMV3编码格式
</p>
当然还可以支持更多视频格式只要FFmpeg支持的按理都能支持您也可参考
<el-link
href="https://github.com/wanwu/cheetah-capture"
target="_blank"
style="margin: 0 4px 5px; font-size: 16px"
>
cheetah-capture
</el-link>
<el-link
href="https://github.com/jordiwang/web-capture"
target="_blank"
style="margin: 0 4px 5px; font-size: 16px"
>
web-capture
</el-link>
修改并编译wasm等文件强烈推荐在Ubuntu系统进行编译
<p>
mac系统推荐安装
<el-link
href="https://github.com/utmapp/UTM"
target="_blank"
style="margin: 0 4px 5px; font-size: 16px"
>
UTM
</el-link>
虚拟机windows系统推荐安装VMware虚拟机
</p>
<p>
当然这只是一个视频帧截取工具如果您想要更多操作可以研究下
<el-link
href="https://ffmpegwasm.netlify.app/"
target="_blank"
style="margin: 0 4px 5px; font-size: 16px"
>
ffmpeg.wasm
</el-link>
它是基于 FFmpeg 的纯 WebAssembly / JavaScript
工具可以在浏览器内进行视频和音频录制转换和流式传输等不过通过一些实践对于时长较长的视频性能还是不太行不过用于时长较短的短视频还是可以上生产的
</p>
</span>
</div>
</template>
<div class="flex flex-wrap">
<el-upload
drag
:show-file-list="false"
accept=".mp4,.mov,.avi,.webm,.mkv"
:before-upload="beforeUpload"
>
<div class="el-upload__text">
可拖拽上传视频默认截取16张帧图片可在代码中自行修改
</div>
</el-upload>
<el-image
v-if="curImg"
:src="curImg"
:preview-src-list="Array.of(curImg)"
class="w-[180px] h-[180px] ml-2 rounded-[6px]"
/>
</div>
<div
v-loading="loading"
element-loading-text="温馨提示:可左右拖拽图片并单击选取所需的帧图片"
id="canvas-container"
class="w-full h-[200px] overflow-hidden mt-6"
/>
</el-card>
</template>
<style scoped lang="scss">
::v-deep(.el-upload-dragger) {
display: flex;
align-items: center;
height: 180px;
}
</style>

View File

@ -43,6 +43,7 @@ function init() {
//
wavesurfer.value.on("ready", () => {
if (!wavesurfer.value) return;
const { duration } = wavesurfer.value;
const { m, s } = getTime(duration);
totalTime.value = `${m}:${s}`;