Webcamで撮影した画像をS3にアップロードする

## この記事について

SPAのWebアプリ上で画像を撮影し（ローカルからアップロードするのではなく）、その画像をS3のバケットにアップロードする実装をしたので紹介します。

## 技術構成と全体の流れ
- フロント： Next.js / RTKQuery
- バックエンド： Laravel
- サーバー： AWS

S3へのアップロードは、LaravelのAPIから署名付きURLを送ってくれるのでそちらを使用します。
フロント→Laravel→S3ではなく、フロントから直接署名付きURLを使い、S3とコネクトするみたいなイメージです。

### 流れ
1. フロントがLaravelに署名付きURLが欲しいとGETする
1. 返ってきたS3アップロード用のURLに、直接画像をPUTする
1. S3のアップロードが完了したら、LaravelのPOSTを叩く
（その際に送るのは、①で返ってきたfilenameだけ）
1. POSTすると、バックエンド側で画像をS3のバケット/uploading（仮） → /uploaded（本番）に移してくれる

※ここでいう /uploadingと/uploadedというのはバックエンドの方が作ってくれたものです。
/uploadingは一定期間を過ぎると削除される設定が入っており、
/uploadedは、実際にアプリで使用されている画像が入っているので勝手に削除されません。

より詳しい処理の流れはこちらを参照ください
[PHPアプリでファイルをクライアントからAWS S3に直接JavaScriptで送付する方法](https://qiita.com/niisan-tokyo/items/052e7ec7e4924a7d6354)


## 画像を撮影
では早速実際にフロントが対応する手順を紹介します。

今回、アプリ内で画像を撮影する仕組みを用意するのに、Webcamというライブラリを使いました。
https://github.com/mozmorris/react-webcam


とても簡単に撮影とプレビュー表示が可能です。


```/pages/photo/[id]/index.tsx
const videoConstraints = {
  width: 414,
  height: 552,
  aspectRatio: 3 / 4,
  facingMode: 'environment'
}

const Photo: NextPage = () => {
  const {
    webcamRef,
    capture,
    imageUrl,
    setImageUrl
  } = usePhotoCapture()

  const {
    createAttendanceHandler,
    isServerError,
    setIsServerError,
    isLoading
  } = usePhotoUpload()

  const retry = () => {
    setImageUrl(null)
    setIsServerError(false)
  }

  return (
    <>
      {imageUrl === null ? (
        <Layout hasHeader={false}>
          <Flex flexDirection={'column'} minH={'100vh'}>
            <Flex position={'relative'} flexGrow={1}>
              <Webcam
                audio={false}
                width={414}
                height={552}
                ref={webcamRef}
                screenshotFormat="image/jpeg"
                videoConstraints={videoConstraints}
                style={{
                  width: '100%',
                  objectFit: 'fill'
                }}
              />
            </Flex>
            <Flex
              align={'center'}
              justify={'center'}
              bgColor={'gray.600'}
              h={'16.4rem'}
            >
              <ButtonPhoto onClick={capture} />
            </Flex>
          </Flex>
        </Layout>
      ) : (
        <Layout headerLayout={'title'} title={'回答の提出'}>
          <Container py={'2.4rem'} px={'1.6rem'} textAlign={'center'}>
            {isServerError && (
              <Box mb={'2.4rem'} textAlign={'left'}>
                <AlertMessage
                  title={
                    '画像のアップロードに失敗しました。お手数ですが、アップロードし直してください。'
                  }
                />
              </Box>
            )}
            <Image
              src={imageUrl}
              alt={''}
              w={'27.3rem'}
              h={'auto'}
              mx={'auto'}
            />
            <Box mt={'2.4rem'}>
              <Button
                variant={'primary'}
                disabled={isServerError}
                onClick={() => createAttendanceHandler(imageUrl)}
              >
                回答を送信する
              </Button>
            </Box>
            <Box mt={'1.6rem'}>
              <Button variant={'secondary'} onClick={retry}>
                撮影し直す
              </Button>
            </Box>
          </Container>
          {isLoading && <LoadingSpinner text={'送信中'} />}
        </Layout>
      )}
    </>
  )
}

export default React.memo(Photo)

```

関係のない部分は削除しています。
スタイルは、ChakraUIを使っております。
https://chakra-ui.com/

今回はアップロードのお話なので、Webcamの詳細は省きます。

## 画像を取得

Webcamで撮影された画像はbase64のデータ形式になります。

base64とは何か？というのはこちらの記事がとても参考になります。
[base64ってなんぞ？？理解のために実装してみた](https://qiita.com/PlanetMeron/items/2905e2d0aa7fe46a36d4)

簡単に言うと、***バイナリーデータを 64 を基数とする表現に変換するエンコード方式***です。
めちゃくちゃ長いテキストデータになります。

余談ですが、わざわざエンコードしてbase64方式で画像を表示するというのが一時期流行ったようです。（今も主流なのかな？）
こんな記事も参考になりました。
[base64エンコード画像表示テクニックよりも「lazyload」の方がブログ表示は遙かに速いんです](https://oops0011.blog.fc2.com/blog-entry-339.html)
→画像の大きさや内容によっては、エンコードしてしまうと容量大きくなってしまい逆に表示時間かかるという話です。

photo.tsxで差し込んでいるカスタムhookの中身です。
こちらで画像を取得し、imageUrlをstateで持っておきます。

```use-photo-capture.ts
import { useRef, useState, useCallback } from 'react'
import Webcam from 'react-webcam'

export const usePhotoCapture = () => {
  const webcamRef = useRef<Webcam>(null)
  const [imageUrl, setImageUrl] = useState<string | null>(null)

  const capture = useCallback(() => {
    const imageSrc = webcamRef.current?.getScreenshot()
    if (imageSrc) {
      setImageUrl(imageSrc)
    }
  }, [webcamRef])

  return {
    webcamRef,
    capture,
    imageUrl,
    setImageUrl
  }
}

```

## 画像をPUTする

取得した画像を実際にS3にアップロードしています。

```use-photo-upload.ts
  const fileUploadToS3 = async (file: string) => {
    try {
      const result = await dispatch(
        fileUploadApi.endpoints.getUploadFileLimitedPath.initiate(undefined, {
          forceRefetch: true
        })
      )

      const res = await fetch(file)
      const blob = await res.blob()

      if (result.data) {
        await uploadImage({
          url: result.data.pre_signed_url,
          data: blob
        })
        return result.data.filename
      }
    } catch {
      setIsServerError(true)
    }
  }
```

やっていること
1. LaravelAPIから署名付きURLをGETする
1. 引数で渡されたimageUrlをblobの形に変換する
1. 変換完了後、S3（署名付きURL)にPUTする

といたってシンプルです。

blobの変換方法ですが、一つ一つのテキストを解析する方法もあったのですが、fetchという簡単な方法があったので真似しました。

[Converting a base64 string to a blob in JavaScript](https://ionicframework.com/blog/converting-a-base64-string-to-a-blob-in-javascript/)

Blob以外も受けれているのですが、Blobでないとアップロード後に実際の画像表示ができなかったのでbase64は変換する必要がありました。
※これは設定によると思いますが

## 最終的にLaravelにPOSTする

アップロードが無事完了したら、LaravelのAPIにアップロード済みの画像をPOSTします。
fileNameは署名付きURLのGETした時にURLと一緒に返ってきています。

```use-photo-upload.ts
  const createAttendanceHandler = async (file: string) => {
    try {
      setIsLoading(true)

      const fileName = await fileUploadToS3(file)

      if (!fileName) throw setIsServerError(true)

      if (fileName) {
        const param = {
          id: Number(id),
          body: {
            filename: fileName
          }
        }
        await createAttendance(param).unwrap()
        router.push('/photo/1/submitted')
      }
    } catch {
      setIsServerError(true)
    } finally {
      setIsLoading(false)
    }
  }
```

断片的だとわかり辛いと思うので、全体のカスタムhookのコードも載せます。

```use-photo-upload.ts
import { useState } from 'react'
import { useRouter } from 'next/router'
import { useAppDispatch } from '@/rtk/hooks'
import { fileUploadApi } from '@/rtk/services/server'
import { useUploadImageMutation } from '@/rtk/services/s3'
import {
  useCreateTestAttendanceMutation,
  useCreatePractoceAttendanceMutation
} from '@/rtk/services/server'

export const usePhotoUpload = () => {
  const router = useRouter()
  const { id, type } = router.query
  const dispatch = useAppDispatch()
  const [isServerError, setIsServerError] = useState(false)
  const [isLoading, setIsLoading] = useState(false)

  const attendanceType = type === 'test' ? 'test' : 'practice'

  const [uploadImage] = useUploadImageMutation()

  const attendanceMutation =
    attendanceType === 'test'
      ? useCreateTestAttendanceMutation
      : useCreatePractoceAttendanceMutation
  const [createAttendance] = attendanceMutation()

  const fileUploadToS3 = async (file: string) => {
    try {
      const result = await dispatch(
        fileUploadApi.endpoints.getUploadFileLimitedPath.initiate(undefined, {
          forceRefetch: true
        })
      )

      const res = await fetch(file)
      const blob = await res.blob()

      if (result.data) {
        await uploadImage({
          url: result.data.pre_signed_url,
          data: blob
        })
        return result.data.filename
      }
    } catch {
      setIsServerError(true)
    }
  }

  const createAttendanceHandler = async (file: string) => {
    try {
      setIsLoading(true)

      const fileName = await fileUploadToS3(file)

      if (!fileName) throw setIsServerError(true)

      if (fileName) {
        const param = {
          id: Number(id),
          body: {
            filename: fileName
          }
        }
        await createAttendance(param).unwrap()
        router.push('/photo/1/submitted')
      }
    } catch {
      setIsServerError(true)
    } finally {
      setIsLoading(false)
    }
  }

  return {
    fileUploadToS3,
    createAttendanceHandler,
    isServerError,
    setIsServerError,
    isLoading
  }
}
```

本当であれば、RTKQueryのローディングやエラーとかが使えるのですが、それぞれの状態を個別で指定するのが面倒だったので、useStateで一気にまとめました（まとめたつもりです）。

## まとめ
画像のアップロードをWebAPIで受け付けるのは色々不都合があるようです。
フロントから直接アップロードする仕組みはとても効率的ですね！