🥵

Rails+Nextで動画ストリーミングをつくる

に公開

こんにちは、株式会社mofmofの大村です。
今回は技術検証兼技術のキャッチアップとして動画のストリーミング再生機能を作成しました。
動画のアップロード機能、サムネイル一覧、ストリーミング再生と動画サービスでの一通りの機能を作成したので以下にまとめていきます。
また、今回ストリーミング再生をするにあたり動画ファイルのHLS変換を行ったのですが、パターンとしてサーバー上でのffmpeg変換(Railsジョブ)と、AWS MediaConvert(Lambda経由)の両方で実装しました。

使用技術

  • Ruby: 3.3
  • Rails: 8.0
  • Next.js: 15
  • DB: PostgreSQL 16
  • ストレージ: S3

0. 環境構築

backendはrailsのAPIモード、frontendはNextで作成します。

backend

rails new . --api -d postgresql \
  --skip-jbuilder \
  --skip-hotwire \
  --skip-action-text \
  --skip-action-mailbox \
  --css=propshaft

frontend

npx create-next-app@latest . \
  --ts --eslint --app --use-npm

1. 動画アップロード機能の作成

動画をS3にアップロードするページ、機能の作成です。

Videoモデルの作成

ActiveStorageを使用して動画ファイルを管理するため、まずVideoモデルを作成します。

rails active_storage:install
rails g model Video

動画の処理状態を管理するためのステータスとHLS関連のカラムを追加するマイグレーションを作成します。

rails g migration AddHlsColumnsToVideos status:integer hls_master_key:string group_id:string

マイグレーションファイルでステータスのデフォルト値とインデックスを設定します。

class AddHlsColumnsToVideos < ActiveRecord::Migration[8.0]
  def change
    add_column :videos, :status, :integer, null: false, default: 0
    add_column :videos, :hls_master_key, :string
    add_column :videos, :group_id, :string

    add_index :videos, :status
    add_index :videos, :hls_master_key
    add_index :videos, :group_id
  end
end

rails db:migrateを実行

Videoモデルにenumerizeを使用してステータス管理とActiveStorageの関連付けを設定します。

gem "enumerize"

bundle installを実行

class Video < ApplicationRecord
  extend Enumerize

  enumerize :status, in: { uploading: 0, processing: 1, ready: 2, failed: 3 }

  has_one_attached :file
  has_one_attached :thumbnail
  has_one_attached :hls_playlist

  def hls_url
    key  = hls_master_key.presence
    base = ENV["HLS_BASE_URL"].presence || "https://#{ENV.fetch("AWS_BUCKET")}.s3.amazonaws.com"
    "#{base}/#{key}"
  end
end

S3の設定

  • Gemfileにaws-sdk-s3を追加: Gemfile
gem 'aws-sdk-s3'

bundle installを実行

  • ストレージ設定: config/storage.yml
amazon:
  service: S3
  access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
  secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
  region: <%= ENV['AWS_REGION'] %>
  bucket: <%= ENV['AWS_BUCKET'] %>
  • 環境設定

    • ActiveStorage: config/environments/production.rbdevelopment.rb)で
      config.active_storage.service = :amazonを設定
    • CORS: config/initializers/cors.rbhttp://localhost:3001またはorigin を許可
  • バケットポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadHLS",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::bucketname/videos/*/hls/*"
        }
    ]
}
  • CORS
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT",
            "POST",
            "HEAD"
        ],
        "AllowedOrigins": [
            "http://localhost:3001",
            "http://localhost:3000"
            // 必要であれば本番環境のURLを追記
        ],
        "ExposeHeaders": [
            "ETag"
        ],
        "MaxAgeSeconds": 3000
    }
]
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allowed = ENV.fetch("CORS_ORIGINS", "http://localhost:3001").split(",").map(&:strip)

  allow do
    origins(*allowed)

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

DirectUploadのカスタマイズ

S3にサブフォルダ構造でファイルを保存するため、ActiveStorageのDirectUploadsControllerをカスタマイズします。

# config/initializers/active_storage_direct_uploads.rb
Rails.application.config.to_prepare do
  class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
    def create
      gid = params[:group_id].to_s
      raise ActionController::BadRequest, "group_id missing" if gid.blank? || gid == "undefined"

      upload_type = params[:upload_type].to_s
      
      filename = params.dig(:blob, :filename).to_s
      ext = File.extname(filename).downcase
      
      if upload_type == "rails"
        key = "videos/rails/#{gid}/#{SecureRandom.hex(16)}#{ext}"
      else
        key = "videos/mc/#{gid}/#{SecureRandom.hex(16)}#{ext}"
      end

      attrs = params.require(:blob)
                    .permit(:filename, :byte_size, :checksum, :content_type)
                    .to_h.symbolize_keys

      blob = ActiveStorage::Blob.create_before_direct_upload!(**attrs.merge(key: key))
      render json: direct_upload_json(blob)
    end
  end
end

Videosコントローラの作成

動画のアップロードと管理を行うコントローラを作成します。

class VideosController < ApplicationController
  def index
    videos = Video.where(status: :ready).with_attached_thumbnail
                  .order(id: :desc).map { |v| serialize(v) }
    render json: videos
  end

  def show
    render json: serialize(Video.find(params[:id]))
  end

  def precreate
    v = Video.create!
    render json: { id: v.id }
  end

  def update
    v = Video.find(params[:id])
    v.file.attach(params.require(:video).permit(:file)[:file])
    v.group_id ||= v.id.to_s
    v.save!
    GenerateThumbnailJob.perform_later(v.id)
    
    head :no_content
  end

  def update_with_hls
    v = Video.find(params[:id])
    v.file.attach(params.require(:video).permit(:file)[:file])
    v.group_id ||= v.id.to_s
    v.save!
    GenerateThumbnailJob.perform_later(v.id)
    GenerateHlsJob.perform_later(v.id)
    
    head :no_content
  end

  def hls_complete
    v = Video.find(params[:id])
    body = request.request_parameters.presence || JSON.parse(request.raw_post) rescue {}
    master_key = body["master_key"].presence ||
                 "videos/#{v.group_id || v.id}/hls/master.m3u8"

    v.update!(status: :ready, hls_master_key: master_key)
    head :ok
  end

  private

  def serialize(video)
    {
      id: video.id,
      title: (video.file.attached? ? video.file.blob.filename.to_s : "無題"),
      created_at: video.created_at.iso8601,
      status: video.status,
      thumbnail_url: (video.thumbnail.attached? ? rails_blob_url(video.thumbnail, only_path: false) : nil),
      hls_url: video.hls_url
    }
  end
end

ルーティングも設定します。

# config/routes.rb
Rails.application.routes.draw do
  resources :videos, only: %i[index show update] do
    collection { post :precreate }
    member do 
      post :hls_complete
      put :update_with_hls
    end 
  end
end

フロントエンドのアップロード機能

Next.jsでActiveStorageのDirectUploadを使用したアップロード機能を実装します。

// src/app/upload/components/uploadForm.tsx
"use client"

import { DirectUpload } from "@rails/activestorage"
import React, { useRef } from "react"
import { useToast } from "@chakra-ui/react"

const API = process.env.NEXT_PUBLIC_API_URL!

export default function UploadForm() {
  const inputRef = useRef<HTMLInputElement>(null)
  const toast = useToast()

  const directUpload = (file: File, groupId: string) =>
    new Promise<{ signed_id: string }>((resolve, reject) => {
      const url = `${API}/rails/active_storage/direct_uploads?group_id=${encodeURIComponent(groupId)}&upload_type=rails`
      new DirectUpload(file, url).create((err: any, blob: any) => {
        if (err) return reject(err)
        resolve({ signed_id: blob.signed_id })
      })
    })

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    const file = inputRef.current?.files?.[0]
    if (!file) {
      toast({ title: "エラー", description: "ファイルを選択してください。", status: "error" })
      return
    }

    toast({ title: "アップロード開始", description: "処理中...", status: "info" })

    try {
      const pre = await fetch(`${API}/videos/precreate`, { method: "POST" })
      const { id } = await pre.json() as { id: number }
      const { signed_id } = await directUpload(file, String(id))

      await fetch(`${API}/videos/${id}/update_with_hls`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ video: { file: signed_id } }),
      })

      toast({ title: "完了", description: "アップロードしました。ffmpegでHLS変換中です。", status: "success" })
      if (inputRef.current) inputRef.current.value = ""
    } catch (err) {
      console.error(err)
      toast({ title: "失敗", description: "アップロードに失敗しました。", status: "error" })
    }
  }

  return (    
    <div style={{ maxWidth: 500, margin: "0 auto", padding: "40px 20px", fontFamily: "Arial, sans-serif" }}>
      <form onSubmit={handleSubmit}>
        <input type="file" ref={inputRef} accept="video/*" required />
        <button type="submit">アップロード</button>
      </form>
    </div> 
  )
}

2. サムネイル生成機能の作成

動画のサムネイルを自動生成するジョブを作成します。ffmpegを使用して動画の1秒目からサムネイルを切り出します。

Docker環境の準備

Dockerfileにffmpegとimagemagickを追加します。

# Dockerfile
FROM ruby:3.3

RUN apt-get update -qq && apt-get install -y \
    --no-install-recommends ffmpeg imagemagick \
    nodejs \
    postgresql-client \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# ...以下続く

サムネイル生成ジョブの作成

# app/jobs/generate_thumbnail_job.rb
class GenerateThumbnailJob < ApplicationJob
  queue_as :default

  def perform(id)
    video = Video.find_by(id: id)
    return unless video&.file&.attached?

    Dir.mktmpdir do |dir|
      out = File.join(dir, "thumbnail.jpg")

      video.file.blob.open do |src|
        thumbnail = system("ffmpeg", "-y", "-ss", "1", "-i", src.path,
                    "-frames:v", "1", "-vf", "scale=320:-1",
                    out, out: File::NULL, err: File::NULL)
        return unless thumbnail && File.size?(out)
      end

      video_key = video.file.blob.key
      if video_key.include?("videos/rails/")
        thumbnail_key = "videos/rails/#{video.id}/thumbnail.jpg"
      else
        thumbnail_key = "videos/mc/#{video.id}/thumbnail.jpg"
      end

      video.thumbnail.purge_later if video.thumbnail.attached?
      video.thumbnail.attach(
        io: File.open(out, "rb"),
        filename: "thumbnail.jpg",
        content_type: "image/jpeg",
        key: thumbnail_key
      )
    end
  end
end

3. 動画一覧ページの作成

サムネイル付きの動画一覧ページを作成します。

フロントエンド実装

// src/app/videos/page.tsx
"use client"

import { 
  Box, 
  SimpleGrid, 
  Card, 
  CardBody, 
  Image, 
  Heading, 
  Text, 
  Container,
  AspectRatio
} from "@chakra-ui/react"
import Link from "next/link"
import { useEffect, useState } from "react"

type Video = { 
  id: number; 
  title: string; 
  created_at: string; 
  thumbnail_url: string | null; 
  hls_url: string | null 
}

const API = process.env.NEXT_PUBLIC_API_URL!

export default function VideoList() {
  const [videos, setVideos] = useState<Video[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const load = async () => {
      const res = await fetch(`${API}/videos`, { cache: "no-store" })
      if (!res.ok) throw new Error("fetch error")
      const data: Video[] = await res.json()
      setVideos(data)
      setLoading(false)
    }
    load()
  }, [])

  return (
    <Container maxW="container.xl" py={8}>
      <Heading mb={8} size="lg" color="gray.700">動画一覧</Heading>
      <SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
        {videos.map(v => (
          <Link key={v.id} href={`/videos/${v.id}`} style={{ textDecoration: 'none' }}>
            <Card overflow="hidden" cursor="pointer">
              <AspectRatio ratio={16/9}>
                {v.thumbnail_url ? (
                  <Image
                    src={v.thumbnail_url}
                    alt={v.title}
                    objectFit="cover"
                    w="100%"
                    h="100%"
                  />
                ) : (
                  <Box bg="gray.200" display="flex" alignItems="center" justifyContent="center">
                    サムネイル生成中…
                  </Box>
                )}
              </AspectRatio>
              <CardBody>
                <Heading size="md" color="gray.800" noOfLines={2}>
                  {v.title}
                </Heading>
                <Text fontSize="sm" color="gray.500">
                  {new Date(v.created_at).toLocaleDateString('ja-JP')}
                </Text>
              </CardBody>
            </Card>
          </Link>
        ))}
      </SimpleGrid>
    </Container>
  )
}

4. HLS変換機能の実装

動画をHLS(HTTP Live Streaming)形式に変換する機能を2パターンで実装しました。

4.1 Railsジョブでのffmpeg HLS変換

サーバー上でffmpegを使ったHLS変換を行うジョブを作成します。

# app/jobs/generate_hls_job.rb
class GenerateHlsJob < ApplicationJob
  queue_as :default

  def perform(id)
    video = Video.find_by(id: id)
    return unless video&.file&.attached?

    Dir.mktmpdir do |dir|
      hls_dir  = File.join(dir, "hls")
      FileUtils.mkdir_p(hls_dir)
      playlist = File.join(hls_dir, "index.m3u8")
      segments = File.join(hls_dir, "%03d.ts")

      ok = false
      video.file.blob.open do |src|
        ok = system(
          "ffmpeg", "-y", "-i", src.path,
          "-c:v", "libx264", "-c:a", "aac",
          "-f", "hls",
          "-hls_time", "4",
          "-hls_list_size", "0",
          "-hls_segment_filename", segments,
          playlist,
          out: File::NULL, err: File::NULL
        )
      end
      return unless ok && File.exist?(playlist)

      video.hls_playlist.purge_later if video.hls_playlist.attached?
      video.hls_playlist.attach(
        io: File.open(playlist, "rb"),
        filename: "index.m3u8",
        content_type: "application/vnd.apple.mpegurl",
        key: "videos/rails/#{video.id}/hls/index.m3u8"
      )

      service  = ActiveStorage::Blob.service
      base_key = "videos/rails/#{video.id}/hls"
      Dir.glob(File.join(hls_dir, "*.ts")).sort.each do |seg|
        File.open(seg, "rb") do |f|
          service.upload("#{base_key}/#{File.basename(seg)}", f, content_type: "video/mp2t")
        end
      end
    end
  end
end

4.2 AWS MediaConvertでのHLS変換

AWS MediaConvertを使用したHLS変換をLambda経由で実装します。

Lambda関数:HLS変換開始

// lambda/hls-convert.mjs(AWS上でデプロイ)
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import { MediaConvertClient, CreateJobCommand } from "@aws-sdk/client-mediaconvert";

const REGION = "ap-northeast-1";
const s3 = new S3Client({ region: REGION });
const mc = new MediaConvertClient({ region: REGION });

const VIDEO_EXT = [".mp4", ".mov", ".m4v", ".mkv"];

export const handler = async (event) => {
  for (const record of event.Records ?? []) {
    const bucket = record.s3.bucket.name;
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
    if (!VIDEO_EXT.some(ext => key.toLowerCase().endsWith(ext))) continue;

    const m = key.match(/^videos\/mc\/([^/]+)\/(?!hls\/).+$/);
    if (!m) continue;
    const videoId = m[1];

    const masterKey = `videos/mc/${videoId}/hls/master.m3u8`;
    try {
      await s3.send(new HeadObjectCommand({ Bucket: process.env.OUTPUT_BUCKET, Key: masterKey }));
      continue;
    } catch {}

    const inputUrl = `s3://${bucket}/${key}`;
    const destination = `s3://${process.env.OUTPUT_BUCKET}/videos/mc/${videoId}/hls/`;

    const params = {
      Role: process.env.MC_ROLE_ARN,
      Settings: {
        Inputs: [{
          FileInput: inputUrl,
          AudioSelectors: {
            "Audio Selector 1": { DefaultSelection: "DEFAULT" }
          }
        }],
        OutputGroups: [{
          Name: "Apple HLS",
          OutputGroupSettings: {
            Type: "HLS_GROUP_SETTINGS",
            HlsGroupSettings: {
              Destination: destination,
              SegmentLength: 6,
              MinSegmentLength: 0,
              DirectoryStructure: "SINGLE_DIRECTORY",
              ManifestCompression: "NONE",
              OutputSelection: "MANIFESTS_AND_SEGMENTS"
            }
          },
          Outputs: [{
            NameModifier: "_720",
            VideoDescription: {
              Width: 1280,
              Height: 720,
              CodecSettings: {
                Codec: "H_264",
                H264Settings: {
                  Bitrate: 3_500_000,
                  RateControlMode: "CBR",
                  GopSize: 48,
                  GopSizeUnits: "FRAMES",
                  FramerateControl: "INITIALIZE_FROM_SOURCE"
                }
              }
            },
            AudioDescriptions: [{
              AudioSourceName: "Audio Selector 1",
              CodecSettings: {
                Codec: "AAC",
                AacSettings: {
                  Bitrate: 128_000,
                  CodingMode: "CODING_MODE_2_0",
                  SampleRate: 48_000
                }
              }
            }],
            ContainerSettings: { Container: "M3U8" }
          }]
        }]
      },
      UserMetadata: { videoId }
    };

    await mc.send(new CreateJobCommand(params));
  }
  return { ok: true };
};

Lambda関数:HLS変換完了通知

// lambda/hls-complete.mjs(AWS上でデプロイ)
export const handler = async (event) => {
  const API_URL = process.env.API_URL;
  const MASTER_RE = /^videos\/(?:mc|rails)\/([^/]+)\/hls\/(.+)\.m3u8$/;
  const VARIANT_RE  = /_(\d{3,4}|audio|sub)\.m3u8$/;
  
  for (const rec of event.Records ?? []) {
    const key = decodeURIComponent(rec.s3.object.key.replace(/\+/g, " "));
    const m = key.match(MASTER_RE);
    
    if (!m) continue;
    if (VARIANT_RE.test(key)) continue; 

    const videoId = m[1];
    const res = await fetch(`${API_URL}/videos/${videoId}/hls_complete`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ master_key: key })
    });
    if (!res.ok) console.error("Webhook failed", res.status, await res.text());
  }
  return { ok: true };
};

AWS側では、S3のイベント通知でLambda関数をトリガーし、MediaConvert完了時にRailsのAPIに通知する仕組みを構築しています。

AWS用アップロードフォーム

// src/app/upload-aws/components/awsUploadForm.tsx
export default function AwsUploadForm() {
  // ...

  const directUpload = (file: File, groupId: string) =>
    new Promise<{ signed_id: string }>((resolve, reject) => {
      const url = `${API}/rails/active_storage/direct_uploads?group_id=${encodeURIComponent(groupId)}&upload_type=aws`
      new DirectUpload(file, url).create((err: any, blob: any) => {
        if (err) return reject(err)
        resolve({ signed_id: blob.signed_id })
      })
    })

  const handleSubmit = async (e: React.FormEvent) => {
    // ...
    try {
      const pre = await fetch(`${API}/videos/precreate`, { method: "POST" })
      const { id } = await pre.json() as { id: number }
      const { signed_id } = await directUpload(file, String(id))

      // AWS用のエンドポイント(HLS変換はLambdaで実行)
      await fetch(`${API}/videos/${id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ video: { file: signed_id } }),
      })

      toast({ 
        title: "完了", 
        description: "アップロードしました。MediaConvertでHLS変換が開始されます。", 
        status: "success" 
      })
    } catch (err) {
      // ...
    }
  }
  // ...
}

5. 動画のストリーミング再生

HLS.jsを使用してブラウザでHLS動画をストリーミング再生する機能を実装します。

HLS.jsの導入

// package.json
{
  "dependencies": {
    "hls.js": "^1.0.0"
  }
}

動画再生ページの実装

// src/app/videos/[id]/page.tsx
"use client";

import { useEffect, useState, useRef } from "react";
import { useParams } from "next/navigation";
import Hls from "hls.js";

type Video = {
  id: number;
  title: string;
  created_at: string;
  thumbnail_url: string | null;
  hls_url: string | null;
  status: string;
};

const API = process.env.NEXT_PUBLIC_API_URL!;

export default function VideoStreamPage() {
  const params = useParams();
  const videoId = params.id;
  const [video, setVideo] = useState<Video | null>(null);
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    if (!videoId) return;
    
    fetch(`${API}/videos/${videoId}`)
      .then(res => res.json())
      .then(setVideo);
  }, [videoId]);

  useEffect(() => {
    const videoElement = videoRef.current;
    if (!videoElement || !video?.hls_url) return;
    
    const hls = new Hls({ enableWorker: true });
    hls.loadSource(video.hls_url);
    hls.attachMedia(videoElement);
    
    return () => hls.destroy();
  }, [video]);

  if (!video) {
    return <div>読み込み中...</div>;
  }

  return (
    <div>
      <video
        ref={videoRef}
        controls
        style={{ width: "100%", height: "100%" }}
      />
      <h1>{video.title}</h1>
      <p>{new Date(video.created_at).toLocaleDateString('ja-JP')}にアップロード</p>
    </div>
  );
}

デザインを整えて完成!

まとめ

今回、RailsとNext.jsを使って動画ストリーミングサービスを構築しました。主な実装内容は以下の通りです:

  1. 動画アップロード機能: ActiveStorageのDirectUploadを使用してS3に直接アップロード
  2. サムネイル生成: ffmpegを使用した自動サムネイル生成ジョブ
  3. HLS変換:
    • Railsジョブでのffmpeg変換
    • AWS MediaConvertを使用したLambda経由の変換
  4. ストリーミング再生: HLS.jsを使用したブラウザでの動画再生

2つのHLS変換方式を実装することで、小規模なサービスから大規模なサービスまでどちらでも利用できるパターンを試してみました。

参考記事

https://zenn.dev/redheadchloe/articles/e924ab767b40d5
https://tech.crassone.jp/posts/hls-video-on-rails
https://til.hashrocket.com/posts/gorrgmnkdo-activestorage-direct-upload-subfolders?utm_source=chatgpt.com
https://techtekt.persol-career.co.jp/entry/tech/241216_01#HLSの基本概要
https://qiita.com/yonaka15/items/09d41f722fa226c2d48c

mofmof inc.

Discussion