mirror of
https://gitee.com/yiming_chang/vue-pure-admin.git
synced 2024-12-01 18:57:39 +08:00
feat: 添加视频帧截取-WebAssembly
版,支持MP4
、MOV
、AVI
、WebM
、MKV
等主流格式
This commit is contained in:
parent
611b591da8
commit
fe5ed68ff7
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
public/wasm/capture.worker.js linguist-language=Vue
|
||||||
|
public/wasm/index.js linguist-language=Vue
|
@ -68,6 +68,7 @@ menus:
|
|||||||
hsguide: Guide
|
hsguide: Guide
|
||||||
hsAble: Able
|
hsAble: Able
|
||||||
hsMenuTree: Menu Tree
|
hsMenuTree: Menu Tree
|
||||||
|
hsVideoFrame: Video Frame Capture
|
||||||
hsWavesurfer: Audio Visualization
|
hsWavesurfer: Audio Visualization
|
||||||
hsOptimize: Debounce、Throttle、Copy、Longpress Directives
|
hsOptimize: Debounce、Throttle、Copy、Longpress Directives
|
||||||
hsWatermark: Water Mark
|
hsWatermark: Water Mark
|
||||||
|
@ -68,6 +68,7 @@ menus:
|
|||||||
hsguide: 引导页
|
hsguide: 引导页
|
||||||
hsAble: 功能
|
hsAble: 功能
|
||||||
hsMenuTree: 菜单树结构
|
hsMenuTree: 菜单树结构
|
||||||
|
hsVideoFrame: 视频帧截取-wasm版
|
||||||
hsWavesurfer: 音频可视化
|
hsWavesurfer: 音频可视化
|
||||||
hsOptimize: 防抖、截流、复制、长按指令
|
hsOptimize: 防抖、截流、复制、长按指令
|
||||||
hsWatermark: 水印
|
hsWatermark: 水印
|
||||||
|
11349
public/wasm/capture.worker.js
Normal file
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
BIN
public/wasm/capture.worker.wasm
Executable file
Binary file not shown.
5477
public/wasm/index.js
Normal file
5477
public/wasm/index.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,15 @@ export default {
|
|||||||
rank: able
|
rank: able
|
||||||
},
|
},
|
||||||
children: [
|
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",
|
path: "/able/wavesurfer",
|
||||||
name: "Wavesurfer",
|
name: "Wavesurfer",
|
||||||
|
@ -8,6 +8,13 @@ type Events = {
|
|||||||
tagViewsShowModel: string;
|
tagViewsShowModel: string;
|
||||||
logoChange: boolean;
|
logoChange: boolean;
|
||||||
changLayoutRoute: string;
|
changLayoutRoute: string;
|
||||||
|
imageInfo: {
|
||||||
|
img: HTMLImageElement;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emitter: Emitter<Events> = mitt<Events>();
|
export const emitter: Emitter<Events> = mitt<Events>();
|
||||||
|
185
src/views/able/video-frame/canvasRenderer.ts
Normal file
185
src/views/able/video-frame/canvasRenderer.ts
Normal 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();
|
||||||
|
// };
|
||||||
|
}
|
172
src/views/able/video-frame/index.vue
Normal file
172
src/views/able/video-frame/index.vue
Normal 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>
|
||||||
|
的截帧工具,支持MP4、MOV、AVI、WebM、MKV等主流格式,支持
|
||||||
|
H.264(AVC)、H.265(HEVC)、MPEG-2、MPEG-4、VP8、VP9、WMV3编码格式
|
||||||
|
</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>
|
@ -43,6 +43,7 @@ function init() {
|
|||||||
|
|
||||||
// 当音频已解码并可以播放时触发
|
// 当音频已解码并可以播放时触发
|
||||||
wavesurfer.value.on("ready", () => {
|
wavesurfer.value.on("ready", () => {
|
||||||
|
if (!wavesurfer.value) return;
|
||||||
const { duration } = wavesurfer.value;
|
const { duration } = wavesurfer.value;
|
||||||
const { m, s } = getTime(duration);
|
const { m, s } = getTime(duration);
|
||||||
totalTime.value = `${m}:${s}`;
|
totalTime.value = `${m}:${s}`;
|
||||||
|
Loading…
Reference in New Issue
Block a user