page-cover
개발
Next.js appDir에서 Video 파일을 Stream 방식으로 내보내기
sooros5132-avatarsooros51325/8/2023Next.js 적용기
Video를 Range Stream이 아닌 그냥 파일 Stream으로 내보낼 경우 비디오 재생을 할 때 시간 이동이 안된다. 따라서 Range Stream 방식을 하려면
  1. http status가 206(Partial Content)일 것
  1. 헤더에 range가 필수로 있어야 할 것
  1. 브라우저가 range시작 값을 보내주는데 청크사이즈를 계산해서 start/end를 알려주고 보내줄 것
  1. 비디오 총 길이와 청크 사이즈도 필요하다.
을 갖추면 브라우저에서 비디오로 인식하고 알아서 제어한다.
먼저 브라우저의 비디오 요청 헤더를 보면 아래와 같다.
Chrome
Chrome의 경우는 Start는 있지만 End값이 없다. 서버에서 청크 크기만큼 짤라서 줘도 되고 비디오 전체를 줘도 지원한다는 얘기다.
Safari
Safari의 경우 첫번째 요청으로 Range: bytes=0-1 딱 1바이트만큼 요청을 한다. 1바이트보다 더 준다면 미디어 형식으로 인식하지 않고 재생이 안된다.
첫번째 요청을 잘 받으면 두번째 요청에 0부터 파일 크기만큼 요청을 한다.
Safari에서 바이트 범위의 End가 파일 크기만큼 요청을 하지만 서버에서 청크 크기만큼 짤라서 보내줘도 인식을 잘 한다. 요청한 바이트 범위보다 부족하다면 추가로 계속 요청을 하니 바이트 범위의 Start값에만 맞춰서 잘 주면 된다.
위 요구사항들에 맞춰서 코드를 작성하면 아래와 같다. Stream만 있으면 다운로드가 안돼서 기본적인 Get요청일 상황도 있으니 같이 작성해 준다.
typescript
import { NextResponse } from 'next/server';
import { promises as fs } from 'fs';
import { lookup } from 'mime-types';

export async function GET(request: Request) {
  try {
    const urlObject = new URL(request.url);
    const searchParams = urlObject.searchParams;
    const isDownload = searchParams.get('download') === 'true';

    const range = request.headers.get('range');

    const stat = await fs.stat('/path/video.mp4');
    const file = await fs.open('/path/video.mp4', 'r');

    const videoSize = stat?.size;

    // Video Stream
    if (range && stat) {
      // 1024 * 1024 * 2 = 2MB (4K 이상은 1MB로는 부족한지 인식이 안돼서 2MB로 늘렸다)
      const CHUNK_SIZE = 1024 * 1024 * 2;

      const parts = range.replace(/bytes=/, '').split('-');
      const start = parseInt(parts[0], 10);

      // end 값으로 start + 청크사이즈 또는 비디오의 마지막 이라면 비디오의 최대 길이로 보내준다.
      const end =
        parts[1] && parseInt(parts[1]) < CHUNK_SIZE
          ? parseInt(parts[1], 10)
          : Math.min(start + CHUNK_SIZE, videoSize - 1);

      const contentLength = end - start + 1;

      const videoStream = file.createReadStream({ start, end });

      return new Response(videoStream as any, {
        headers: {
          'Content-Range': `bytes ${start}-${end}/${videoSize}`,
          'Accept-Ranges': 'bytes',
          'Content-Length': `${contentLength}`,
          'Content-Type': lookup(videoPath) || 'video/mp4'
        },
        status: 206
      });
    }

    // Video Download
    const videoStream = file.createReadStream();

    return new Response(videoStream as any, {
      headers: {
        'Content-Length': `${videoSize}`,
        'Content-Type': lookup('/path/video.mp4') || 'video/mp4',
        //! filename*=utf-8 인코딩과 binary 설정으로 한글 이름이 깨지지 않도록 해준다.
        'Content-Disposition': `${isDownload ? 'attachment; ' : ''} filename="${Buffer.from(
          'video.mp4'
        ).toString('binary')}"; filename*=utf-8''${encodeURIComponent(
          'video.mp4'
        )};`
      },
      status: 200
    });
  } catch (error) {
    return NextResponse.json(
      { error },
      { status: 400 }
    );
  }
}
return으로 new Response의 데이터로 fs로 만든 ReadStream을 내보내주면 된다.
타입스크립트라면 타입 에러가 날텐데 videoStream as any 까지 해주면 됨.
위 코드는 Chrome 113, macOS Safari 16.3, iOS Safari 16.3에서 테스트 했고 정상 작동한다.
Safari에서 재생이 안되는 경우가 있는데 VP9, AV1, … 등 비디오 코덱의 미지원 문제이다. 소리만 안나오는 경우도 브라우저의 오디오 코덱 미지원 때문이다.