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.rb(development.rb)で
config.active_storage.service = :amazonを設定 - CORS:
config/initializers/cors.rbでhttp://localhost:3001またはorigin を許可
- ActiveStorage:
-
バケットポリシー
{
"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を使って動画ストリーミングサービスを構築しました。主な実装内容は以下の通りです:
- 動画アップロード機能: ActiveStorageのDirectUploadを使用してS3に直接アップロード
- サムネイル生成: ffmpegを使用した自動サムネイル生成ジョブ
-
HLS変換:
- Railsジョブでのffmpeg変換
- AWS MediaConvertを使用したLambda経由の変換
- ストリーミング再生: HLS.jsを使用したブラウザでの動画再生
2つのHLS変換方式を実装することで、小規模なサービスから大規模なサービスまでどちらでも利用できるパターンを試してみました。
参考記事
Discussion