Bootstrap

Vue2 使用 Knova Canvas 合成图片、多个视频、音频在一个画面中并播放,自定义 video control 控制条

效果图如下:
预览效果图

安装插件

npm install vue-konva@2 konva --save

在main.js中使用

import Vue from 'vue';
import VueKonva from 'vue-konva';

Vue.use(VueKonva);

相关实现代码

html

<template>
  <div class="video-preview-wrapper">
    <div ref="videoPreviewBox" class="video-preview-box">
      <div class="video-box">
        <v-stage ref="stage" :config="stageConfig" @click="onControl">
          <v-layer ref="layer">
            <v-image ref="frame" :config="imageConfig" />
          </v-layer>
          <v-layer>
            <v-image v-for="(cover, index) in videoCovers" :key="index" :config="cover" />
          </v-layer>
        </v-stage>
      </div>
      <div class="control-play">
        <div class="control-play-btn" @click="onControl">
          <i
            :class="[{ 'el-icon-video-pause': isPlay }, { 'el-icon-video-play': !isPlay }]"
          />
        </div>
        <div class="control-progress common-progress">
          <div>
            <el-slider
              v-model="videoProgress"
              :show-tooltip="false"
              :max="canvas.duration"
              input-size="small"
              @change="onProgressChange"
            />
          </div>
        </div>
        <div class="current-time">{{ currentTime }}</div>
        /
        <div class="duration">{{ duration }}</div>
        <div class="video-speed-box">
          <el-dropdown placement="bottom" @command="onCommand">
            <div class="video-speed-show">{{ playbackRate }}x</div>
            <el-dropdown-menu slot="dropdown">
              <el-dropdown-item command="1">0.5x</el-dropdown-item>
              <el-dropdown-item command="2">1x</el-dropdown-item>
              <el-dropdown-item command="3">1.5x</el-dropdown-item>
              <el-dropdown-item command="4">2x</el-dropdown-item>
              <el-dropdown-item command="5">3x</el-dropdown-item>
            </el-dropdown-menu>
          </el-dropdown>
        </div>
        <div class="control-voice common-progress">
          <span class="voice-icon" />
          <div class="voice-slider">
            <el-slider v-model="voiceProgress" input-size="small" @change="onVoiceChange" />
          </div>
        </div>
        <div class="fullscreen" title="全屏" @click="onFullScreen">
          <i class="el-icon-full-screen" />
        </div>
      </div>
    </div>
  </div>
</template>

相关方法

export default {
  name: 'VideoPreview',
  data() {
    return {
      stageConfig: {
        width: window.innerWidth,
        height: (window.innerHeight - 64),
      },
      imageConfig: {
        image: null,
        width: window.innerWidth,
        height: (window.innerHeight - 64),
      },
      canvas: {
        duration: 10,
        volume: 1,
        playbackRate: 1,
        frames: [
          {
            imageUrl: require('./bg1.jpg'),
            duration: 3,
            videos: [
              {
                x: 20,
                y: 100,
                width: 200,
                height: 200,
                cover: require('./VfE_html5.jpg'),
                url: require('./VfE_html5.mp4'),
                volume: 1,
                playbackRate: 1,
              },
              {
                x: 420,
                y: 100,
                width: 200,
                height: 200,
                cover: require('./video_thumb.jpg'),
                url: require('./video.mp4'),
                volume: 1,
                playbackRate: 1,
              },
            ],
            audios: [
              {
                url: require('./dengnixiake.flac'),
                volume: 1,
              },
            ],
          },
          {
            imageUrl: require('./bg2.jpg'),
            duration: 3,
            videos: [
              {
                x: 100,
                y: 100,
                width: 200,
                height: 200,
                cover: require('./VfE_html5.jpg'),
                url: require('./flower.mp4'),
                volume: 1,
                playbackRate: 1,
              },
            ],
            audios: [],
          },
          {
            imageUrl: require('./bg3.jpg'),
            duration: 2,
            videos: [],
            audios: [
              {
                url: require('./shuohaobuku.flac'),
                volume: 1,
              },
            ],
          },
          {
            imageUrl: require('./bg4.jpg'),
            duration: 2,
            videos: [],
            audios: [],
          },
        ],
      },
      videoCovers: [],
      videos: [],
      audios: [],
      isPlay: false,
      duration: 0,
      currentTime: '00:00:00',
      videoProgress: 0,
      playbackRate: 1,
      voiceProgress: 100,
      videoTimeTimer: null,
      videoSceneTimer: null,
      videoTimers: [],
      audioTimers: [],
    };
  },
  watch: {
    videoProgress(value) {
      // 如果播放完成,则暂停播放,清除视频时间定时器
      if (value === this.canvas.duration) {
        this.isPlay = false;
        clearInterval(this.videoTimeTimer);
      }
      // 更换视频背景图
      this.canvas.frames.forEach(({ imageUrl, startAt, endAt }) => {
        if (value >= startAt && value < endAt) {
          const img = new Image();
          img.src = imageUrl;
          img.onload = () => {
            if (`http://localhost:8080${imageUrl}` !== this.imageConfig.image.src) {
              this.imageConfig.image = img;
            }
          };
        }
      });

      // 暂停不在播放时间范围内的窗口视频
      this.videos
        .filter(({ startAt, endAt }) => (this.videoProgress < startAt || this.videoProgress > endAt))
        .forEach(({ videoObj }) => {
          videoObj.pause();
          videoObj.currentTime = 0;
        });

      // 暂停不在播放时间范围内的音频
      this.audios
        .filter(({ startAt, endAt }) => (this.videoProgress < startAt || this.videoProgress > endAt))
        .forEach(({ audioObj }) => {
          audioObj.pause();
          audioObj.currentTime = 0;
        });
    },
  },
  created() {
    this.duration = this.formatVideoTime(this.canvas.duration);
    // 计算出每一个场景的开始时间、结束时间
    const durationList = this.canvas.frames.map(({ duration }) => duration);
    durationList.reduce((prev, current, idx) => {
      if (idx <= 1) {
        this.canvas.frames[0].startAt = 0;
        this.canvas.frames[0].endAt = prev;
      }
      this.canvas.frames[idx].startAt = prev;
      this.canvas.frames[idx].endAt = prev + current;
      return prev + current;
    });

    // 将所有场景的窗口视频、音频初始化
    this.canvas.frames.forEach(({ videos, audios, startAt, endAt }) => {
      videos.forEach((video) => {
        const videoObj = document.createElement('video');
        videoObj.src = video.url;
        videoObj.muted = true;
        videoObj.addEventListener('play', () => {
          this.videoCovers = [];
          this.timerCallback();
        });
        this.videos.push({
          videoObj,
          x: video.x,
          y: video.y,
          width: video.width,
          height: video.height,
          startAt,
          endAt,
        });
      });

      audios.forEach((audio) => {
        const audioObj = document.createElement('audio');
        audioObj.src = audio.url;
        audioObj.volume = audio.volume;
        this.audios.push({ audioObj, startAt, endAt });
      });
    });

    if (this.canvas.frames?.[0]) {
      // 第一个场景的视频封面
      this.videoCovers = this.canvas.frames[0].videos?.map(({ cover, x, y, width, height }) => {
        const image = new Image();
        image.src = cover;
        return { x, y, width, height, image };
      });
      // 第一个场景的背景图
      const img = new Image();
      img.src = this.canvas.frames[0].imageUrl;
      img.onload = () => {
        this.imageConfig.image = img;
      };
    }
  },
  mounted() {
    this.ctx = this.$refs.frame.getNode().getContext('2d');
    // 在没有cover的情况下,可设置视频首帧为封面
    this.videos
      .filter(({ startAt, endAt }) => (this.videoProgress >= startAt && this.videoProgress < endAt))
      .forEach(({ videoObj, x, y, width, height }) => {
        videoObj.addEventListener('loadeddata', () => {
          videoObj.play();
          this.ctx.drawImage(videoObj, x, y, width, height);
          setTimeout(() => {
            videoObj.pause();
          }, 100);
        });
      });
  },
  methods: {
    timerCallback() {
      this.videos
        .filter(({ startAt, endAt }) => (this.videoProgress >= startAt && this.videoProgress < endAt))
        .forEach(({ videoObj, x, y, width, height }) => {
          if (videoObj.paused || videoObj.ended) {
            return;
          }
          this.ctx.drawImage(videoObj, x, y, width, height);
          clearTimeout(this.videoSceneTimer);
          this.videoSceneTimer = setTimeout(() => {
            this.timerCallback();
          }, 0);
        });
    },
    onControl() {
      this.isPlay = !this.isPlay;
      if (this.canvas.duration <= this.videoProgress) {
        this.videoProgress = 0;
      }
      this.controlPlay();
    },
    onProgressChange(val) {
      // 设置窗口小视频的播放进度
      this.videos
        .filter(({ startAt, endAt }) => (this.videoProgress >= startAt && this.videoProgress < endAt))
        .forEach(({ videoObj, startAt }) => {
          videoObj.currentTime = val - startAt;
        });
      // 设置音频的播放进度
      this.audios
        .filter(({ startAt, endAt }) => (this.videoProgress >= startAt && this.videoProgress < endAt))
        .forEach(({ audioObj, startAt }) => {
          audioObj.currentTime = val - startAt;
        });
      this.updateVideoProgress();
      this.controlPlay();
      // 显示在播放时间范围内的窗口视频
      this.videos
        .filter(({ startAt, endAt }) => (this.videoProgress > startAt && this.videoProgress < endAt))
        .forEach(({ videoObj, x, y, width, height }) => {
          videoObj.play();
          this.ctx.drawImage(videoObj, x, y, width, height);
          setTimeout(() => {
            videoObj.pause();
          }, 100);
        });
    },
    controlPlay() {
      clearInterval(this.videoTimeTimer);
      if (this.isPlay) {
        // 定时器定时更新视频时间
        this.videoTimeTimer = setInterval(() => {
          this.updateVideoProgress();
        }, 1000 / this.playbackRate);
      }

      this.videoTimers = [];
      this.audioTimers = [];
      this.videos.forEach(({ videoObj, startAt, endAt }) => {
        // 控制视频的播放、暂停
        if (this.videoProgress >= startAt && this.videoProgress < endAt) {
          if (this.isPlay) {
            videoObj.play();
          } else {
            videoObj.pause();
          }
        }
        // 控制即将播放的视频的播放、暂停
        if (this.videoProgress < startAt) {
          const videoTimer = setTimeout(() => {
            if (this.isPlay) {
              videoObj.play();
            } else {
              videoObj.pause();
            }
          }, (startAt - this.videoProgress + 1) * 1000);
          this.videoTimers.push(videoTimer);
        }
      });
      this.audios.forEach(({ audioObj, startAt, endAt }) => {
        // 控制音频的播放、暂停
        if (this.videoProgress >= startAt && this.videoProgress < endAt) {
          if (this.isPlay) {
            audioObj.play();
          } else {
            audioObj.pause();
          }
        }
        // 控制即将播放的音频的播放、暂停
        if (this.videoProgress < startAt) {
          const audioTimer = setTimeout(() => {
            if (this.isPlay) {
              audioObj.play();
            } else {
              audioObj.pause();
            }
          }, (startAt - this.videoProgress + 1) * 1000);
          this.audioTimers.push(audioTimer);
        }
      });
    },
    updateVideoProgress() {
      if (this.videoProgress >= this.canvas.duration) {
        this.videoProgress = this.canvas.duration;
      } else {
        this.videoProgress += 1;
      }
      this.currentTime = this.formatVideoTime(this.videoProgress);
    },
    formatVideoTime(time) {
      const currentTime = time;
      let hour = parseInt(currentTime / 3600, 10);
      let minute = parseInt((currentTime % 3600) / 60, 10);
      let seconds = parseInt(currentTime % 60, 10);
      hour = hour < 10 ? `0${hour}` : hour;
      minute = minute < 10 ? `0${minute}` : minute;
      seconds = seconds < 10 ? `0${seconds}` : seconds;
      return `${hour}:${minute}:${seconds}`;
    },
    onCommand(val) {
      let playbackRate = 0;
      switch (val) {
        case '1':
          playbackRate = 0.5;
          break;
        case '2':
          playbackRate = 1;
          break;
        case '3':
          playbackRate = 1.5;
          break;
        case '4':
          playbackRate = 2;
          break;
        case '5':
          playbackRate = 3;
          break;

        default:
          playbackRate = 1;
          break;
      }
      this.playbackRate = playbackRate;
      this.videos
        .filter(({ startAt, endAt }) => (this.videoProgress >= startAt && this.videoProgress < endAt))
        .forEach(({ videoObj }) => {
          videoObj.playbackRate = playbackRate;
        });
      this.audios
        .filter(({ startAt, endAt }) => (this.videoProgress >= startAt && this.videoProgress < endAt))
        .forEach(({ audioObj }) => {
          audioObj.playbackRate = playbackRate;
        });
    },
    onVoiceChange(val) {
      const newVolume = val / 100;
      this.audios
        .filter(({ startAt, endAt }) => (this.videoProgress >= startAt && this.videoProgress < endAt))
        .forEach(({ audioObj }) => {
          audioObj.volume = newVolume;
        });
    },
    onFullScreen() {
      const element = this.$refs.videoPreviewBox;
      const isFullScreen = document.fullscreen || document.mozFullScreen || document.webkitIsFullScreen ||
      document.webkitFullScreen || document.msFullScreen;
      if (isFullScreen) {
        if (document.exitFullscreen) {
          document.exitFullscreen();
        } else if (document.mozCancelFullScreen) {
          document.mozCancelFullScreen();
        } else if (document.msExitFullscreen) {
          document.msExitFullscreen();
        } else if (document.webkitExitFullscreen) {
          document.webkitExitFullscreen();
        }
        return;
      }

      if (element.requestFullscreen) {
        element.requestFullscreen();
      } else if (element.mozRequestFullScreen) {
        element.mozRequestFullScreen();
      } else if (element.msRequestFullscreen) {
        element.msRequestFullscreen();
      } else if (element.webkitRequestFullscreen) {
        element.webkitRequestFullScreen();
      }
    },
  },
};

css样式

.video-preview-wrapper {
  position: relative;
  height: 100%;

  .video-preview-box {
    .video-box {
      position: absolute;
    }

    .control-play {
      width: 100%;
      position: absolute;
      left: 0;
      bottom: 5%;
      display: flex;
      align-items: center;
      padding: 0 10px;
      color: #fff;

      .control-play-btn {
        margin-right: 20px;
        font-size: 24px;
        cursor: pointer;
      }

      .control-progress {
        width: 60%;
      }

      .current-time {
        margin: 0 10px 0 20px;
      }

      .duration {
        margin-left: 10px;
      }

      .video-speed-box {
        width: 40px;
        display: flex;
        justify-content: center;
        margin: 0 20px;
        background-color: aliceblue;
        cursor: pointer;

        .el-dropdown {
          width: 100%;
          text-align: center;
        }
      }

      .control-voice {
        width: 10%;
      }

      .fullscreen {
        margin-left: 20px;
        cursor: pointer;
      }
    }
  }
}
;