アップロードする動画の一部を画像として切り出す(Nuxt.js/Vue.js/Vuetify)

# 概要
以前の記事の応用です。
https://qiita.com/Yoshihiro-Hirose/items/82b9dccdf270551db887
動画ファイルを選択してプレビュー表示して、その一部をマウスでクリックして画像に切り出します。

# 動作サンプル
CodePenで動作するサンプルを用意しました。
狭いので右上のロゴから別ウィンドウを推奨します。
適当な動画を選択して、マウスでクリックしてみてください。
※CodePen 上で動かすと座標が多少ずれます
<p class="codepen" data-height="265" data-theme-id="default" data-default-tab="js,result" data-user="yoshihiro-hirose" data-slug-hash="XWWqNQy" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="SelectVideoPointClip">
  <span>See the Pen <a href="https://codepen.io/yoshihiro-hirose/pen/XWWqNQy">
  SelectVideoPointClip</a> by Yoshihiro Hirose (<a href="https://codepen.io/yoshihiro-hirose">@yoshihiro-hirose</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

# 処理の流れ
1. マウスカーソルの画像を用意
2. 動画ファイル選択時に表示する video 要素にクリックイベントを登録
3. クリック時の座標を計算して canvas に書き出す

## マウスカーソルの画像を用意
クリップする感を出すためにカーソル用の画像を用意します。
枠があって中が透過されていれば OK。
![clip_cursol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/517184/dd249ddd-668a-435c-056f-b54f7d018177.png)

static ディレクトリに画像を配置して、vue ファイルにもスタイルを定義して video 要素から参照させます。

```vue
<style lang="sass" scoped>
.clipable
  cursor: url('/clip_cursol.png'), crosshair
</style>
```

## 動画ファイル選択時に表示する video 要素にクリックイベントを登録
`@click` で関数を指定してビデオ内のクリックを捕捉します。
再生ボタンや早送りなどのコントロールはクリックイベントの対象外です。

```vue
          <video
            :
            :
            :
            @click="clip"
          >
```

## クリック時の座標を計算して canvas に書き出す
画像を書き出す canvas を用意しておきます。

```vue
          <canvas
            id="c"
            :width="matching_image_side"
            :height="matching_image_side"
          ></canvas>
```

ビデオクリック時の関数は以下のようになりました。

```js
    clip(event) {
      const video = document.getElementById('upload-video')
      const canvas = document.getElementById('c')

      // 実際のビデオサイズ
      const orgVideoWidth = video.videoWidth
      const orgVideoHeight = video.videoHeight

      // ブラウザ上の表示サイズ
      const eleVideoWidth = video.width
      const eleVideoHeight = video.height

      // 割合を計算して処理していく
      const widthRatio = orgVideoWidth / eleVideoWidth
      const heightRatio = orgVideoHeight / eleVideoHeight

      // カーソルの枠分ピクセルを調整
      const pointX = event.offsetX + 3
      const pointY = event.offsetY + 3

      const srcX = pointX * widthRatio
      const srcY = pointY * heightRatio

      canvas
        .getContext('2d')
        .drawImage(
          video,
          srcX,
          srcY,
          this.matching_image_side,
          this.matching_image_side,
          0,
          0,
          this.matching_image_side,
          this.matching_image_side
        )
    },
```

以下工夫ポイント
- クリックした座標からマウスカーソルの枠分を考慮して調整
- drawImage で video から書き出す際、実際の動画のサイズが基準になっている。
  - ブラウザ上で表示した場合に比べて差があるので、割合を計算して調整している。

## おまけ
canvas に書き出した画像を API からアップロードしたい場合、Blob を生成して multipart で送れます。
詳細は全体のコードを確認してください。

## ファイル全体

```vue:sample.vue
<template>
  <v-row justify="center">
    <v-col sm="12" md="11" lg="9" xl="6">
      <v-sheet class="pa-3">
        <h1>動画のマウスクリックによる一部切り出し</h1>
        <v-form ref="form">
          <video
            v-if="uploadVideoUrl"
            id="upload-video"
            class="clipable"
            controls
            width="640"
            height="360"
            @click="clip"
          >
            <source :src="uploadVideoUrl" />
            このブラウザではビデオ表示がサポートされていません
          </video>
          <v-file-input
            v-model="work_video"
            accept="video/*"
            show-size
            label="適当な動画ファイルを選択してください"
            prepend-icon="mdi-video"
            @change="onVideoPicked"
          ></v-file-input>
          <canvas
            id="c"
            :width="matching_image_side"
            :height="matching_image_side"
          ></canvas>
          <span v-if="uploadVideoUrl"
            >※動画をクリックして選択箇所を画像に切り出します</span
          >
        </v-form>
        <v-row align="center" justify="center" class="mt-12 mb-4 px-5">
          <v-btn outlined color="iconcolor" rounded block @click="post"
            >サーバにPOSTする予定ボタン</v-btn
          >
        </v-row>
      </v-sheet>
    </v-col>
  </v-row>
</template>

<script>
export default {
  data() {
    return {
      work_video: null,
      matching_image_side: '28',
      matching_image: null,
      uploadVideoUrl: ''
    }
  },
  methods: {
    onVideoPicked(file) {
      if (file !== undefined && file !== null) {
        if (file.name.lastIndexOf('.') <= 0) {
          return
        }
        const fr = new FileReader()
        fr.readAsDataURL(file)
        fr.addEventListener('load', () => {
          this.uploadVideoUrl = fr.result
        })
      } else {
        this.uploadVideoUrl = ''
      }
    },
    clip(event) {
      const video = document.getElementById('upload-video')
      const canvas = document.getElementById('c')

      // 実際のビデオサイズ
      const orgVideoWidth = video.videoWidth
      const orgVideoHeight = video.videoHeight

      // ブラウザ上の表示サイズ
      const eleVideoWidth = video.width
      const eleVideoHeight = video.height

      // 割合を計算して処理していく
      const widthRatio = orgVideoWidth / eleVideoWidth
      const heightRatio = orgVideoHeight / eleVideoHeight

      // カーソルの枠分ピクセルを調整
      const pointX = event.offsetX + 3
      const pointY = event.offsetY + 3

      const srcX = pointX * widthRatio
      const srcY = pointY * heightRatio

      canvas
        .getContext('2d')
        .drawImage(
          video,
          srcX,
          srcY,
          this.matching_image_side,
          this.matching_image_side,
          0,
          0,
          this.matching_image_side,
          this.matching_image_side
        )
    },
    async post() {
      const blobPromise = new Promise(function(resolve, reject) {
        const canvas = document.getElementById('c')
        canvas.toBlob(
          function(blob) {
            resolve(blob)
          },
          'image/jpeg',
          1.0
        )
      })

      const formData = new FormData()
      formData.append('image', await blobPromise, 'test.jpg')

      // API で切り抜いた画像をアップロードできる
      // await this.$axios.post('/api/test', formData, {
      //   headers: {
      //     'Content-Type': 'multipart/form-data'
      //   }
      // })
    }
  }
}
</script>

<style lang="sass" scoped>
.clipable
  cursor: url('/clip_cursol.png'), crosshair
</style>
```

# 最後に
指摘事項や質問ありましたらお気軽にコメントください!