会員制の動画を売るアプリを、エンジニア一人で作って運用する — 3年前なら「それはもう無理ですよ」と止めていた計画が、2026年のいまは現実的な選択肢になっています。動画基盤がサービス化され、Rorkの生成コードにサブスク連動を組み込めば、配信インフラを自前で構築しなくてもブランドを持った動画アプリを出せるようになりました。
ただ、実際に作り始めると次の壁が連続します。「HLSって結局どう用意すればいいのか」「FairPlay DRMは個人でも取れるのか」「解約した人の再生を即座に止める仕組みはどう作るのか」「オフライン視聴ってストレージに素のmp4を置くだけじゃダメなのか」。ここではそこで止まった方が今日から手を動かせるように、実装全体を一つの流れで追っていきます。
なぜ「自前の動画配信アプリ」を検討するのか
YouTubeやVimeo OTTに載せるのではなく、自社アプリに抱える理由は、大きく3つに絞られます。
まず収益配分です。YouTubeメンバーシップやVimeo OTTは運営が楽な代わりに売上の30%前後を取られます。Apple Storeの手数料を支払っても、自社アプリのほうが最終利益が残るケースは、月商50万円を超えたあたりから現実的に見えてきます。
次にブランド体験です。広告が挟まらない、プレイリストの並び順を完全に自分で決められる、ホーム画面に並ぶアイコンが自分のロゴになります。このあたりはYouTubeでは絶対に手に入りません。
そして顧客データです。どの動画のどこで離脱されているか、どの章が何度も見返されているか、新規ユーザーが最初に見る動画は何か — こうした行動ログが手元に残れば、コンテンツ制作の精度が上がります。
一方で、自前で組むとDRM・帯域費・CDN・セキュリティと論点が一気に増えます。そのバランスを見極めるのが、この記事の出発点です。
全体アーキテクチャ — 個人開発で現実的な構成
最初に全体像を押さえておきます。動画配信アプリは、一般的なWeb/モバイルアプリと比べて3つの専用レイヤーが追加されます。
- 動画ストレージ/エンコード層: 元動画(ソース)を複数解像度のHLSに変換し、ストリーミング可能な形で配信する層。ここをゼロから組むのは個人開発では非現実的なので、Mux・Cloudflare Stream・Bunny Stream・Muxのいずれかを使います
- 視聴権限層: 誰が何を再生していいかを判定する層。Stripeのサブスクリプション状態とKV/DBを繋げて、再生URLを発行するときに毎回チェックします
- プレイヤー層: Rorkで生成する画面側。expo-videoでHLSを再生し、AirPlay・PiP・オフラインキャッシュを扱います
図式で言えば、ユーザーが「再生ボタン」を押した瞬間に次の流れが走ります。アプリ → 自前バックエンド(Cloudflare Workers等)→ Stripe状態確認 → 動画配信サービスの署名付きURL発行 → アプリのプレイヤーで再生。この一連の流れを短時間で回せる設計にすることが、体感のサクサク感を決めます。
Step 1: 動画配信基盤を選ぶ — Mux・Cloudflare Stream・Bunny Stream の比較
個人開発で選択肢に入るのは、おおむねこの3つです。それぞれ向き不向きがあります。
- Mux: API設計が洗練されていて、React Native SDKもあり、HLSマスタープレイリストの配信とAnalyticsが強力です。視聴時間課金で、視聴数の伸びに比例して課金されるため、初期コストが読みやすいのが特徴です。FairPlay DRMにも対応(Mux Plusプラン)
- Cloudflare Stream: 月額$5から始められる定額課金+ストレージ課金で、個人開発の初動に最も向きます。HLSとDASHが両方出て、署名付きURLの生成もシンプル。DRM対応は別途問い合わせ
- Bunny Stream: 帯域料金が安いのが魅力で、月間10TB流すようなスケールでも他の半額程度。DRM(MediaCage)の追加料金が比較的明瞭
私は個人開発のスタート時はCloudflare Streamを推します。単純な価格体系と、サインドURLのコードがシンプルで、Rorkと組み合わせたときに余計な摩擦が起きないからです。ユーザーが増えて月間視聴時間が大きくなってきたらMuxに寄せるパスが現実的です。
動画のアップロードと再生準備
ここでは例としてCloudflare Streamを使います。Rork側からの直接アップロードは大容量で不安定になりやすいので、管理画面は別途(ダッシュボードで管理するか、Remix/Next.jsの簡易管理画面を作る)でアップロードし、アプリは再生に専念します。
アップロード時に取得できる uid が動画ごとのIDになります。サブスクリプション権限と紐づけるマスタテーブルを作り、「どのプランで見られるか」を定義します。
-- videos テーブル
CREATE TABLE videos (
id TEXT PRIMARY KEY, -- Cloudflare Stream の uid
title TEXT NOT NULL,
description TEXT,
required_plan TEXT NOT NULL, -- 'free' | 'pro' | 'premium'
duration_sec INTEGER,
thumbnail_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Step 2: Rork で HLS プレイヤーを実装する
Rorkに「expo-videoを使ってHLSを再生し、シークバー・再生速度切替・ピクチャーインピクチャー対応のプレイヤーを作って」とプロンプトを投げると、おおむね動く雛形が出てきます。ただしそのままだと、iOSのバックグラウンド再生・音声フォーカス制御・AirPlay対応が抜けていることが多いので、以下のコードをベースに補強します。
// app/components/StreamPlayer.tsx
import { useEvent } from "expo";
import { useVideoPlayer, VideoView } from "expo-video";
import { useEffect, useState } from "react";
import { View, StyleSheet, ActivityIndicator } from "react-native";
type Props = {
videoId: string;
getSignedUrl: (videoId: string) => Promise<string>;
};
export function StreamPlayer({ videoId, getSignedUrl }: Props) {
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// 視聴権限チェック → 署名付きHLS URL取得
useEffect(() => {
let canceled = false;
(async () => {
try {
const url = await getSignedUrl(videoId);
if (!canceled) setSourceUrl(url);
} catch (e: any) {
// 権限エラーはユーザーに原因を明示する(プラン不足 / 未ログイン)
if (!canceled) setError(e.message ?? "動画を読み込めませんでした");
}
})();
return () => { canceled = true; };
}, [videoId, getSignedUrl]);
const player = useVideoPlayer(sourceUrl ?? "", (p) => {
p.loop = false;
p.allowsExternalPlayback = true; // AirPlay 許可
p.timeUpdateEventInterval = 1.0; // 再生位置を1秒ごとに購読
});
// バックグラウンドで音だけ鳴らしたい場合は staysActiveInBackground を有効化
// ただしApp Store審査でバックグラウンド音声モードの理由説明が必要になるため
// 「音声教材アプリ」など正当な用途がある場合のみ有効にすること
// player.staysActiveInBackground = true;
useEvent(player, "statusChange", { status: player.status });
if (error) {
return <View style={styles.centered}><ErrorNotice message={error} /></View>;
}
if (!sourceUrl) {
return <View style={styles.centered}><ActivityIndicator size="large" /></View>;
}
return (
<VideoView
style={styles.video}
player={player}
allowsFullscreen
allowsPictureInPicture
contentFit="contain"
nativeControls
/>
);
}
const styles = StyleSheet.create({
video: { width: "100%", aspectRatio: 16 / 9, backgroundColor: "#000" },
centered: { width: "100%", aspectRatio: 16 / 9, alignItems: "center", justifyContent: "center", backgroundColor: "#111" },
});
このコードのポイントは、useVideoPlayer に URL を直接渡すのではなく、署名付きURLの取得を useEffect 内で非同期に行う点です。こうすることで、Stripeの権限チェックが先に走り、権限がなければ再生URL自体が手元に来ない構造になります。「URLが届いていなければ再生できない」というシンプルな原理に還元できるのが、個人開発での運用で重要です。
Step 3: 署名付きURLの発行と視聴権限チェック
Cloudflare Stream の署名付きURLは、Cloudflare Workers から数行で発行できます。このエンドポイントにStripeのサブスク状態チェックを組み込むのがこの章の主題です。
// workers/api/video-token/route.ts (Cloudflare Workers + Hono 例)
import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import { decode } from "./jwt"; // 自前 JWT 検証
type Bindings = {
STREAM_CUSTOMER_SUBDOMAIN: string;
STREAM_KEY_ID: string;
STREAM_SIGNING_JWK: string; // JWK for HS256
STRIPE_SECRET: string;
DB: D1Database;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post("/video-token/:videoId", async (c) => {
const videoId = c.req.param("videoId");
const session = getCookie(c, "session");
if (!session) return c.json({ error: "unauthorized" }, 401);
// 1) セッションから userId を取り出す
const user = await decode(session, c.env);
if (!user) return c.json({ error: "invalid_session" }, 401);
// 2) videos テーブルから必要プランを確認
const video = await c.env.DB.prepare(
"SELECT required_plan FROM videos WHERE id = ?"
).bind(videoId).first<{ required_plan: string }>();
if (!video) return c.json({ error: "not_found" }, 404);
// 3) Stripe のサブスク状態を確認(KVキャッシュ付き)
const plan = await getActivePlan(user.email, c.env);
if (!canView(plan, video.required_plan)) {
return c.json({
error: "upgrade_required",
currentPlan: plan,
requiredPlan: video.required_plan,
}, 403);
}
// 4) 署名付き JWT を発行(10分有効)
const token = await signStreamToken(videoId, {
exp: Math.floor(Date.now() / 1000) + 600, // 10分
kid: c.env.STREAM_KEY_ID,
jwk: c.env.STREAM_SIGNING_JWK,
});
// Cloudflare Stream の HLS マスタープレイリスト URL
const hlsUrl = `https://${c.env.STREAM_CUSTOMER_SUBDOMAIN}/${token}/manifest/video.m3u8`;
return c.json({ url: hlsUrl, expiresIn: 600 });
});
function canView(active: string | null, required: string) {
if (required === "free") return true;
if (!active) return false;
const rank: Record<string, number> = { pro: 1, premium: 2 };
return (rank[active] ?? 0) >= (rank[required] ?? 0);
}
export default app;
このコードで大事なのは有効期限を短く切ることです。Cloudflare Streamの署名付きURLは、一度発行されるとその有効期限内は誰からでも再生可能になります。解約直後のユーザーが保存していたURLを使い続けられない設計にするには、10分〜30分程度で失効させ、再生中もアプリが定期的にトークンを更新する作りが必要です。
再生中のトークン更新は、useVideoPlayer の timeUpdateEventInterval に合わせて、残り有効期限が2分を切ったら新しいトークンを取得して player.replace(newUrl, currentPosition) で差し替える実装にします。
Step 4: Stripe サブスクリプションと視聴権限の状態同期
ここがこの記事の核です。「解約したユーザーが、翌日から再生できなくなる」この一点を確実にするには、Stripe Webhook → KV/DB 更新のパイプラインを信頼できる状態にする必要があります。
// workers/api/stripe-webhook/route.ts
import { Hono } from "hono";
import Stripe from "stripe";
const app = new Hono<{ Bindings: Bindings }>();
app.post("/stripe/webhook", async (c) => {
const sig = c.req.header("stripe-signature")!;
const body = await c.req.text();
const stripe = new Stripe(c.env.STRIPE_SECRET, { apiVersion: "2025-05-01" });
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body, sig, c.env.STRIPE_WEBHOOK_SECRET,
undefined, Stripe.createSubtleCryptoProvider()
);
} catch (e) {
return c.json({ error: "signature_failed" }, 400);
}
switch (event.type) {
case "checkout.session.completed":
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription | Stripe.Checkout.Session;
await upsertSubscription(sub, c.env);
break;
}
case "customer.subscription.deleted": {
// 解約 → KV からエントリ削除
const sub = event.data.object as Stripe.Subscription;
const email = await resolveEmail(sub, c.env);
await c.env.KV.delete(`plan:${email}`);
break;
}
case "invoice.payment_failed": {
// 支払い失敗 → 3日以内に更新されなければ解約扱いにする運用
await markPaymentFailed(event, c.env);
break;
}
}
return c.json({ received: true });
});
なぜ Webhook を信頼するのかという点は必ず明文化しておいた方がいいです。Stripe API をアプリ側から毎回呼ぶのは、レイテンシーと料金の両面で割に合いません。Webhook でKVに書き、KVを権限チェックの一次情報として扱う — このパターンが最も現実的です。
ただし、Webhook は配信失敗・順序逆転が起きます。対策は2つ:
- リトライ前提の冪等設計: 同じevent.idで二重書き込みされても結果が変わらないように
INSERT OR REPLACE を使う
- 状態逆転の防止:
customer.subscription.updated より後に古い状態のイベントが届くことがあるので、current_period_end のタイムスタンプで比較して新しい情報のみ反映する
この2点を入れておかないと、運用を始めて2週間くらいで「解約したはずなのに再生できる」「課金したのに再生できない」のどちらかが必ず起きます。
Step 5: オフライン視聴の実装と暗号化
オフライン視聴は、画面のない地下鉄やフライト中に視聴してもらうための重要な機能です。ただし、素のmp4をデバイス内に保存するのはやってはいけません。ダウンロードされた動画ファイルをユーザーがFilesアプリ経由で抜き出し、第三者に渡してしまう可能性があります。
現実的なアプローチは次の3択です。
- DRM不要の軽量アプローチ: AES-128 で暗号化した HLS セグメントをダウンロードし、アプリ内の暗号化ストレージに保存します。復号鍵は毎回バックエンドから取得。プロでないユーザーを想定した通常用途はこれで十分
- FairPlay Streaming (FPS): Apple の本格DRM。配信会社(Mux Plus など)と契約し、Apple Developer Programから FPS Deployment Package を取得する必要があります。審査が複雑
- ダウンロード機能そのものを提供しない: 意外ですが、初期リリースではこれが最適解のことも多いです。リソースを本編のコンテンツ制作に回せます
私が個人開発で推すのは、最初はオフライン機能なしで出して、プレミアムプラン加入率が見えてきたら(A)の軽量DRMを足すという順序です。初期から FairPlay を組み込むとリリースが3ヶ月遅れます。
AES-128 の軽量実装パターンは次のような形になります。
// app/lib/offline.ts
import * as FileSystem from "expo-file-system";
import * as Crypto from "expo-crypto";
export async function downloadEncryptedVideo(
videoId: string,
getDownloadManifest: (id: string) => Promise<{ segments: string[]; key: string }>
) {
const { segments, key } = await getDownloadManifest(videoId);
const dir = `${FileSystem.documentDirectory}offline/${videoId}/`;
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
// 復号キーはアプリ内のセキュアストレージ(iOS Keychain / Android Keystore)に保存
await saveKeyToSecureStore(`key:${videoId}`, key);
for (const [index, segmentUrl] of segments.entries()) {
const path = `${dir}seg_${String(index).padStart(5, "0")}.ts`;
await FileSystem.downloadAsync(segmentUrl, path);
}
return dir;
}
async function saveKeyToSecureStore(name: string, key: string) {
// expo-secure-store で AES キーをデバイスのセキュア領域に保存
// 絶対に AsyncStorage に置かないこと — ルート化端末で抜ける
}
ここで重要なのが復号鍵の保存場所です。AsyncStorage に置いてはいけません。必ず expo-secure-store(iOS Keychain / Android Keystore)を使います。さらに、解約されたユーザーがアプリを再開したときには SecureStore からキーごと削除し、オフラインファイルも再生できないようにします。
Step 6: DRM が必要なケースと回避するケースの判断基準
「DRMを入れるべきか」はよく迷いますが、判断軸はシンプルに2つです。
- ストリーミング配信だけ(オフライン不可): 署名付きURL+短い有効期限で十分。DRMなしで構築可能
- オフライン視聴あり: AES-128 軽量暗号化 or FairPlay DRM が必要
さらに、次の条件に一つでも当てはまるなら FairPlay 導入を真剣に検討してください。
- コンテンツ制作者から「他の配信プラットフォームと同等の保護」を明示的に求められている
- 月商100万円以上の配信ビジネスに育ってきて、コンテンツ流出リスクが事業損失に直結する
- 企業研修・医療系など、契約上「保護された配信」を要求される
逆に、自主制作のフィットネス動画や料理レシピ動画など、「漏洩しても致命傷にはならない」コンテンツなら、DRMの実装コストに見合いません。
Step 7: よくある落とし穴 — 初期リリースで8割の人が詰まる3つ
動画配信アプリで個人開発者が必ず詰まるポイントを、先に共有しておきます。
落とし穴 1: バックグラウンド再生モードの審査
Info.plistの UIBackgroundModes に audio を追加すると、ロック画面でも音声が鳴り続けるようになります。しかしこれを入れると、App Store審査で「なぜこの機能が必要か」を必ず聞かれます。教育系・オーディオブック系なら通りますが、理由が曖昧だとリジェクトされます。初期リリースではバックグラウンド再生を入れず、必要になってから機能追加する順序が安全です。
落とし穴 2: AirPlay/Chromecast 使用時の権限バイパス
AirPlayで再生中のコンテンツは、署名付きURLがAppleTVに転送されるため、URLが露出します。サブスク解約直後でもAppleTV側のキャッシュで再生が続く可能性があります。対策は署名URLの有効期限を短く保つこと(10分以内)と、ホスト側で再生セッションを追跡してバックエンドから強制停止する仕組み(長時間の視聴では再トークン取得を必須にする)を用意することです。
落とし穴 3: 「解約後3日経っても再生できる」問題
もっとも起きやすい事故は、Webhookが配信失敗したまま気づかないケースです。Stripe Dashboardの「Events」→「Failed」を監視する仕組みを、リリース前に必ず入れておきます。Cloudflare Workersで月に1回、Stripe Subscriptions APIを叩いてKV状態と突合する夜間ジョブを走らせておくと、Webhookが落ちていても遅くて翌月には気づけます。
運用コストと収支設計 — 具体的な数字
最後に、実際にこのアーキテクチャを運用したときの月額コストのイメージを共有します。月間ユニークユーザー1,000人・平均視聴時間20分を想定した例です。
- Cloudflare Stream: 視聴時間1,000 × 20分 = 20,000分 × $0.001/分 ≒ $20 + ストレージ $5 = $25
- Cloudflare Workers: 無料枠内(10万req/日)で収まる = $0
- Stripe手数料: 3.6% + ¥40/件(国内)、月商10万円なら手数料 約¥4,000
- Apple 手数料: 15%(Small Business Program適用時、月商10万円なら ¥15,000)
月商10万円の段階で、手取りは約70,000円〜80,000円。スケールに応じてMux等へ移行すると効率が上がります。
リリース前に必ず手作業で確認しておきたい5つのシナリオ
課金機能を含むアプリは、壊れていると信頼を一気に失います。自動テストで全て網羅するのは難しいので、リリース前に手作業で次の5シナリオを走らせておくと安心です。
- 新規契約直後の再生: Stripe テストモードで契約し、Webhookが飛び、KVが書き込まれ、
/video-token が成功する、ここまでが10秒以内に完了することを確認します。ここに時間がかかるとUX評価が下がります
- 即時解約の反映:
cancel_at_period_end=false で即時解約し、次の /video-token が 403 を返すことを確認します。cancel_at_period_end=true の場合は current_period_end まで再生可能で、その後停止することも確認します
- 支払い失敗時の猶予期間: 更新時に
card_declined になるテストカードで、3日間は視聴可能・4日目から停止、という運用が意図通り動くことを確認します
- ダウングレード時の権限反映: Premium → Pro へのダウングレード直後に、プレミアム限定動画が 403 になり、Pro 対象動画は再生できることを確認します
- 無料トライアルの自動終了: 7日間のトライアル終了時、未決済のまま期限を迎えたら再生権限が即時に失われることを確認します
この5つが通れば、課金の中核は信頼して運用できる状態です。UIのバグや計測の抜けは、後から直しても顧客の信頼を損ねません。
事業として計測すべき3つの指標 — MRRよりも先に見るべき数字
個人開発者はMRR(月次経常収益)を見がちですが、動画サブスク事業では翌月以降の収益を予測する指標のほうが意思決定に役立ちます。重要な3指標を紹介します。
7日間のアクティベーション率: トライアル登録者のうち7日以内に1本以上の動画を完視聴した人の割合です。40%未満だとトライアル→有料転換率が弱く、60%超ならプロダクト自体は成立しています。その場合の課題は成長(新規獲得)であって製品ではない、という判断が早期にできます。
有料会員の月間視聴分数: 月45分以上視聴している人は、90日以内の解約率が大きく下がります。10分未満の人は60日以内に解約する確率が高いです。コホート別にこの数字を追うと、改善の優先順位が見えます。
1ユーザーあたりのライブラリ広さ: 月に何本の異なる動画を見たかです。月1本の人は価格改定時に離脱しやすく、月5本以上の人は最大40%程度の値上げにも耐えます。ライブラリの「深さ」を作ることが長期LTVに直結します。
この3つは MRR より先行してチャーンを予測するので、ダッシュボードの一等地に置く価値があります。
コンテンツ運用 — 技術的な実装より重要な部分
HLS・Stripe・DRMの実装は、動画配信ビジネスの全体作業の30%程度です。残り70%はコンテンツを制作・アップロード・タイトル付け・サムネイル作成・公開する繰り返しの運用そのものです。ここを軽視するとリリース後に息切れします。
リリース前に決めておきたい運用方針が3つあります。
公開ペース: 12ヶ月続けても燃え尽きないペースを選びます。個人運営だと週1本がスイートスポットです。週2〜3本はフルタイムのクリエイター向けで、副業では3ヶ月目に崩壊するケースが多いです。
サムネイル・タイトル作成のワークフロー: 動画1本につきFigmaのテンプレートで30分以内に仕上げる、という時間制限をつけます。自社アプリ内でもサムネイルの手抜きは発見率を大きく下げます。
古い動画の扱い: 全てを永久保存すると、ストレージコストとライブラリの見通しの悪化を招きます。50本を超えたあたりから「おすすめ」「最新」「アーカイブ」のセクションを分け、全て時系列で並べるのをやめる運用にシフトすると快適です。
全体を振り返って — 次の一歩
ここまで実装を進められる段階まで来たなら、最初に取り組むのは最小構成での公開です。DRMもオフライン機能も後回しで、署名付きURL + Stripeサブスクの組み合わせだけでベータ版を出し、実際のユーザーの反応を見ましょう。「どの動画が見られているか」「どこで解約されているか」が見えたタイミングで、機能を足していくほうが、順番として効率的です。
どちらも今回の記事で触れた設計判断の裏付けになります。
関連記事として、Rork × Stripe サブスク収益化ガイド には課金フロー全般の詳細を、Rork × Cloudflare R2 ファイルストレージガイド には動画のバックアップ用途での使い方を書いています。