用法
<template>
<VideoUploader v-model="video" :width="400" :height="300" @cover="saveVideoCover" />
<img :src="cover" v-if="cover" />
</template>
<script>
export default {
data() {
return {
video: null, // 视频地址
cover: null, // 封面地址
};
},
watch: {
// 删除视频时清除封面图
video(n) {
if (!n) {
this.cover = null;
}
},
},
methods: {
saveVideoCover(data) {
this.cover = data.url;
}
}
}
</script>
VideoUploader.vue
<template>
<div class="uploader" :class="{ uploaded: files.length >= limit }" :style="style">
<el-upload class="upload-button" :limit="limit" :multiple="multiple" :http-request="upload" :file-list="fileList" :accept="accept" :before-upload="fileCheck" :on-remove="remove" :show-file-list="false" action="null" drag v-if="!loading">
<div class="placeholder">
<i :class="icon" class="uploader-icon"></i>
<div class="el-upload__text" v-html="placeholder"></div>
</div>
</el-upload>
<div class="progress" v-if="loading">
<div class="tip" v-if="progress.loaded == progress.total">等待转存…</div>
<template v-else>
<div class="percentage">{{ progress.percentage || '--' }}</div>
<div class="size">{{ progress.loaded || '--' }} / {{ progress.total || '--' }}</div>
</template>
<el-button @click="cancelUpload" size="mini">取消</el-button>
</div>
<div class="files" v-if="files.length">
<div class="file" v-for="(item, i) in files" :key="item.url">
<video :poster="item.poster || item.cover || item.img || item.image || item.imageUrl" muted controls crossorigin="anonymous" ref="videoPlayer">
<source :src="item.url" :type="item.type || 'video/mp4'" />
</video>
<div class="control">
<div @click="capture" class="button capture" title="提取当前画面为视频封面" v-if="buildCover">
<i class="el-icon-camera-solid"></i>
</div>
<div @click="remove(item)" class="button remove" title="删除此视频文件">
<i class="el-icon-delete"></i>
</div>
</div>
</div>
</div>
<canvas ref="screenshot" style="display: none"></canvas>
</div>
</template>
<script>
import { operation as operationApi } from '@/api/file';
import axios from 'axios';
const CancelToken = axios.CancelToken;
let CancelUpload = () => {};
export default {
props: {
// v-model
value: {
type: String,
default: null,
},
icon: {
type: String,
default: 'el-icon-upload',
},
placeholder: {
type: String,
default: '将视频拖到此处,或<em>点击上传</em>',
},
width: {
type: Number,
default: 360,
},
height: {
type: Number,
default: 200,
},
limit: {
type: Number,
default: 1,
},
buildCover: {
type: Boolean,
default: false,
},
},
data() {
return {
files: [],
accept: '.mp4, .ogg, .webm',
progress: {
total: null,
loaded: null,
percentage: null,
},
loading: false,
};
},
computed: {
fileList() {
let self = this;
let list = self.files.map((v) => {
let name = v.url.match(/[^/]+\.\w+$/i);
name = (name && name[0]) || '文件';
name = name.substr(-16);
return {
...v,
name: v.name || name,
};
});
return list;
},
// 允许多选时,el-upload 仍是一张一张上传而不是一次多张
multiple() {
let self = this;
return self.limit > 1;
},
style() {
let self = this;
let style = {
width: self.width + 'px',
height: self.height + 'px',
};
return style;
},
},
methods: {
// 截取视频当前播放帧为封面
capture() {
let self = this;
const video = self.$refs['videoPlayer'][0];
const canvas = self.$refs['screenshot'];
const ctx = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
console.log('capture.intro', { video, canvas, size: { width: canvas.width, height: canvas.height }, files: self.files });
const STATUS = {
initializing: '正在生成',
uploading: '正在上传',
ok: '完成',
error: '错误',
正在生成: 'initializing',
正在上传: 'uploading',
完成: 'ok',
错误: 'error',
};
self.$emit('cover', { status: STATUS.正在生成, STATUS });
// 绘制当前视频帧到canvas上
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, video.videoWidth, video.videoHeight);
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
const loading = self.$loading({
lock: true,
text: '正在生成封面',
background: 'rgba(0, 0, 0, 0.7)',
});
// 将canvas转换为图片 // const image = canvas.toDataURL('image/png');
try {
canvas.toBlob((blob) => {
console.log('capture.blob', blob, 1 ? canvas.toDataURL('image/png') : '');
// 上传
let form = new FormData();
let image = new File([blob], 'screenshot.png', { type: 'image/png' });
form.append('file', image); // append 存在则追加,set 存在则覆盖
operationApi
.upload(form, {
onUploadProgress: function (progressEvent) {
self.$emit('cover', { status: STATUS.正在上传, progressEvent, STATUS });
console.log('封面上传中', progressEvent.loaded, progressEvent.total);
// self.progress.total = Math.floor(progressEvent.total / 1024 / 1024) + 'MB';
// self.progress.loaded = Math.floor(progressEvent.loaded / 1024 / 1024) + 'MB';
// self.progress.percentage = Math.floor((progressEvent.loaded / progressEvent.total) * 100) + '%';
},
cancelToken: new CancelToken(function (cancel) {
CancelUpload = cancel;
}),
})
.then((res) => {
console.log('封面上传.then', res);
self.$emit('cover', { status: STATUS.完成, url: res && res.url, STATUS });
})
.catch((error) => {
console.log('封面上传.catch', error);
self.$emit('cover', { status: STATUS.错误, error, STATUS });
})
.finally(() => {
loading.close();
});
});
} catch (e) {
console.log('capture.error', e);
self.$message.error(`提取封面出错:${e.message}`);
loading.close();
}
},
// self.files 变动时更新父组件的 v-model
emit() {
let self = this;
let value = null;
if (self.files.length == 1) {
value = self.files[0].url;
} else if (self.files.length == 0) {
value = null;
} else {
value = self.files;
}
self.$emit('input', value);
},
remove(file, fileList = []) {
let self = this;
try {
let list = self.files;
for (let i = 0; i < list.length; i++) {
const image = list[i];
if (file.url == image.url) {
list.splice(i, 1);
break;
}
}
} catch (e) {
console.log('移除时出错了', e, file, fileList);
}
self.emit();
return true;
},
upload(params) {
let self = this;
var file = params.file;
var form = new FormData();
form.append('file', file); // append 存在则追加,set 存在则覆盖
self.loading = true;
operationApi
.upload(form, {
onUploadProgress: function (progressEvent) {
self.progress.total = Math.floor(progressEvent.total / 1024 / 1024) + 'MB';
self.progress.loaded = Math.floor(progressEvent.loaded / 1024 / 1024) + 'MB';
self.progress.percentage = Math.floor((progressEvent.loaded / progressEvent.total) * 100) + '%';
},
cancelToken: new CancelToken(function (cancel) {
CancelUpload = cancel;
}),
})
.then((res) => {
if (Array.isArray(res) && res.length > 0) {
res.forEach((img) => {
self.files.push(img);
});
} else {
self.files.push(res);
}
self.emit();
if (self.buildCover) {
self.$nextTick(() => {
// 视频上传后自动生成封面
const video = self.$refs['videoPlayer'][0];
video.addEventListener('loadeddata', function () {
console.log('视频上传完成,第一帧已经加载完毕,准备提取封面图…');
// self.capture(); // 注释原因:部分视频第一帧黑屏,往后播放一下再截图
let delay = 0.5; // 视频进度移至 00:00.5
video.currentTime = Math.min(delay, video.duration);
});
video.addEventListener('seeked', (event) => {
if (!self.coverCaptured) {
self.coverCaptured = true;
self.capture();
}
});
video.addEventListener('error', function (error) {
console.log('视频加载遇到错误', error);
});
// video.addEventListener('canplay', function (res) {
// console.log('媒体数据已经有足够的数据(至少播放数帧)可供播放时触发。这个事件对应CAN_PLAY的readyState。', res);
// });
});
}
console.group('uploaded');
console.log('params:', params);
console.log('uploaded:', res);
console.groupEnd();
})
.catch((e) => {
console.log('upload fail:', e);
})
.finally(() => {
self.loading = false;
self.progress.loaded = null;
self.progress.total = null;
self.progress.percentage = null;
});
},
cancelUpload() {
try {
CancelUpload();
} catch (e) {
console.log('CancelUpload', e);
}
},
fileCheck(file) {
let self = this;
let size = file.size;
const maxSize = 200 * 1024 * 1024;
let passed = size <= maxSize;
if (!passed) {
self.$message.error('文件大小不可超过200MB');
}
return passed;
},
// bind v-model value to el-upload
fill(v) {
let self = this;
if (v) {
self.files = [{ url: v }];
} else {
self.files = [];
}
},
// success(response, file, fileList) {
// console.log('uploader.success', response, file, fileList);
// },
// error(err, file, fileList) {
// console.log('uploader.error', err, file, fileList);
// },
// progress(event, file, fileList) {
// console.log('uploader.progress', event, file, fileList);
// },
// change(file, fileList) {
// console.log('uploader.change', file, fileList);
// },
// beforeUpload(file) {
// console.log('uploader.beforeUpload', file);
// },
// beforeRemove(file, fileList) {
// console.log('uploader.beforeRemove', file, fileList);
// },
},
watch: {
value: function (v, o) {
let self = this;
if (v != o) {
self.fill(v);
}
// console.log('[avatarUploader.vue@watch] value:', v, 'from:', o);
},
},
mounted() {
let self = this;
let v = self.value;
self.fill(v);
// console.log('[avatarUploader.vue@mounted] value:', self.value);
},
};
</script>
<style lang="scss" scoped>
::v-deep.uploader {
width: 360px;
height: 200px;
border-radius: 6px;
overflow: hidden;
background-color: #f3f3f3;
position: relative;
&.uploaded {
&:hover .button {
background: hsla(0, 0%, 0%, 0.3);
}
.upload-button {
display: none;
}
.files {
width: 100%;
height: 100%;
.file {
width: 100%;
height: 100%;
position: relative;
video {
width: 100%;
height: 100%;
background-color: #000;
}
.control {
position: absolute;
right: 10px;
top: 10px;
line-height: 1;
.button {
display: inline-block;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
color: #fff;
cursor: pointer;
border-radius: 2px;
&:hover {
background-color: #000;
}
&.capture {
margin-right: 10px;
}
}
}
}
}
}
.upload-button {
width: 100%;
height: 100%;
.el-upload {
width: 100%;
height: 100%;
.el-upload-dragger {
display: flex;
justify-content: center;
align-items: center;
width: auto;
height: 100%;
.placeholder {
.uploader-icon {
font-size: 67px;
color: #c0c4cc;
margin: 0 0 16px;
}
.el-upload__text {
color: #606266;
font-size: 14px;
text-align: center;
em {
color: #5473e8;
font-style: normal;
}
}
}
}
}
}
.progress {
text-align: center;
display: inline-block;
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 1em;
line-height: 1;
.percentage {
font-size: 24px;
line-height: 1.4;
}
.tip,
.size {
margin-bottom: 0.5em;
}
}
}
</style>