個人開発でiOSアプリを1本リリースしました。Onliという、仲のいい友達数人だけで使うクローズドな動画アプリです。
この記事は宣伝というより、作っていて一番おもしろかった(そして一番ハマった)部分の備忘録です。具体的には、
- 「自分が投稿するまで他人の投稿が一切見えない」という相互性をどこで担保したか
- その日に投稿された複数の横動画を、1本の縦長MP4に積み上げて書き出すネイティブモジュールをAVFoundationで書いた話(座標系で2日溶かした)
- 文字入力じゃなくて通話を主役にした理由
あたりを、実際のコードを抜き出しながら書きます。スタックは Expo SDK 55 / React Native 0.83 / Supabase です。
📱 App Store: https://apps.apple.com/jp/app/onli/id6771083321
なぜ作ったのか
InstagramもBeRealも、結局「知らない人にどう見られるか」を気にする場所になっていきました。フォロワーが増えるほど、投稿のハードルが上がる。気づいたら、本当に近い友達とは個別のDMでしか動画を送っていない。
それなら、最初から数人しか入れないグループ専用にしてしまえばいい、というのが出発点でした。タイムラインも、いいね数も、おすすめも、フォロワーも無い。入れるのは招待された人だけ。
ただ「クローズドなグループアプリ」だけだと、結局みんな見るだけで投稿しない“ROM化”が起きます。これをアプリの仕組みで殺したかった。そこで入れたルールがこれです。
自分がその枠に投稿するまで、他のメンバーの動画は見えない。
見たければ出す。出さなければ見えない。シンプルですが、これが効きました。
1日3枠、2秒。「ループ」という単位
時間を朝・昼・夜の3枠に切って、各枠1投稿までにしています。1枠2秒前後の動画でいい。3枠全部出したら「ループ完了」。
枠の定義はクライアントとプッシュ通知のスケジュールで共有していて、こんな素朴な関数です。
// 朝 05:00–10:59 / 昼 11:00–17:59 / 夜 18:00–翌04:59(夜は日付をまたぐ)
export function windowForHour(hour: number): WindowKey {
if (hour >= 5 && hour < 11) return 'morning';
if (hour >= 11 && hour < 18) return 'day';
return 'evening';
}
1ユーザー × 1グループ × 1枠 = 1投稿という制約は、撮影画面のアップロード直前に最後の番人として効かせています。連打やオフライン復帰での二重投稿を、UIのdisabledだけに頼らないための保険です。
// 撮影画面、アップロード実行の直前
const already = await hasPostedThisWindow(user.id, selectedGroup);
if (already) {
// この枠はもう投稿済み。次の時間帯にどうぞ。
return;
}
「いつでも好きなだけ投稿」ではなく、枠で区切る。これによって、夜に開くと“今日まだ誰とも共有してない”という軽いそわそわが生まれます。SNSの「無限に流れてくる」とは逆で、終わりがあるのがポイントでした。
そして相互性(ヴェール)の実装。動画ファイル自体はCDNキャッシュを効かせたいので Storage は公開読み取りにしていますが、誰がどの枠に出したか/自分が出したかをクエリ層で判定し、出していない枠の中身はそもそも画面に積みません。「投稿すると見られます」というプレースホルダだけが見えている状態です。ハードなRLSで物理的に隠すより、プロダクトの体験として一貫していることを優先しました(このあたりは賛否あると思います)。
本題:その日の動画を「1本の縦長動画」に積む
Onliで個人的に一番気に入っている機能が、今日のリールです。
その日グループに集まった横向きクリップを、横並びのタイルとして縦にスタックした1本のMP4に書き出します。シーケンシャルに繋ぐ(A→B→C)のではなく、全員のクリップが同時に再生される1枚の縦長映像。スクショ1枚に今日の全員が写っているような感覚で、これをそのままカメラロールやストーリーに流せる。
これをJS側でやるのは現実的じゃない(重い・遅い・端末で死ぬ)ので、Expoのネイティブモジュールとして Swift + AVFoundation で書きました。
設計上の方針は1つだけ決めました:レイアウト計算はJS、描画はネイティブ。タイルが1カラムか2カラムか、各タイルの矩形、フォント倍率まで全部JSが算出して、Swiftは「この矩形にこのクリップを描け」だけをやる。ネイティブ側でインデックス計算をやると、あるタイルに別のタイルのキャプションが付く、みたいな“ペアずれ”バグが必ず出るからです。
各クリップの設定はこういうRecordで受け取ります。
struct ReelClipConfig: Record {
@Field var uri: String = ""
@Field var timeLabel: String = "" // 朝 / 昼 / 夜
@Field var captionLabel: String = ""
@Field var usernameLabel: String = ""
@Field var avatarUri: String = "" // 空ならイニシャルのバッジを描く
// キャンバス内のタイル矩形(左上原点・ピクセル)。
// レイアウトはJSが決めるので、ここはただ描くだけ。
@Field var x: Double = 0
@Field var y: Double = 0
@Field var width: Double = 0
@Field var height: Double = 0
@Field var fontScale: Double = 1.0 // 2カラム時は0.7くらいに落とす
}
各クリップをAVMutableVideoCompositionLayerInstructionにして、setCropRectangleで中央クロップ+setTransformで目的のタイル矩形へ移動・スケールする。テキストやアバターのオーバーレイはAVVideoCompositionCoreAnimationToolでCALayerとして重ねる。ここまでは教科書通りです。
2日溶かした座標系の話
問題は、AVFoundationが2つの異なる座標系を平気で混在させてくることでした。
- 動画トラックの
preferredTransformを当てた後のビデオ変換空間は左上原点(top-down) -
AVVideoCompositionCoreAnimationToolで重ねるCALayer側のオーバーレイは左下原点(bottom-up)
最初、横着して両方に同じ上下反転(outputSize.height - y)を当てていました。すると、リールの**一番上のタイルに「夜」、一番下に「朝」**のラベルが付く、という地味に最悪なバグになる。タイルの位置は合っているのに、ラベルだけ上下が逆。プレビューでは気づきにくく、書き出したMP4で初めて発覚するやつです。
正解は「両方に反転を入れる」のではなく、ビデオは反転しないこと。
// クリップ矩形のmidYへ平行移動するだけ。outputSize.height − … の反転は入れない。
// (ここで“ご丁寧に”上下反転を足すと、ラベルとタイルの上下が逆になる)
var transform = assetVideoTrack.preferredTransform
transform = transform.concatenating(
CGAffineTransform(translationX: -cropDisplay.midX, y: -cropDisplay.midY))
transform = transform.concatenating(CGAffineTransform(scaleX: scale, y: scale))
transform = transform.concatenating(
CGAffineTransform(translationX: centerX, y: centerY))
教訓:AVFoundationでオーバーレイ付きの合成をやるときは、「いま自分はtop-downとbottom-upのどっちにいるか」をコメントで明示しながら書く。頭の中だけで反転を追うと必ず溶かします。
ちなみに端末で書き出す関係上、撮影は480pに落として、アップロード前に同じネイティブモジュール側で圧縮しています。「全員のクリップをダウンロード→デコード→合成→エンコード」を実用的な速度に収めるための割り切りです。画質より、今日のうちに完成して共有できることを取りました。
もう一つの賭け:通話を主役にした
OnliにはDMもグループチャットもありますが、一番押したいのは通話です。文字でだらだら続けるより、撮ったクリップをきっかけに「いまちょっと話せる?」で繋がるほうが、近い関係には合っている、という賭けでした。
ここは技術的には一番しんどい部分で、react-native-callkeep + PushKit/CallKit + VoIPプッシュで、アプリが死んでいても着信画面が出るようにしています。ネイティブの着信UIを出すにはEASのdev buildが必須で、トークンの期限切れや環境ズレで「鳴らない」が頻発するため、Expoの通常プッシュをフォールバックに残してあります。VoIPまわりは別記事を書けるくらいハマったので、ここでは触りだけにしておきます。
全体のスタック
特別なことはしていませんが、個人開発のリアルな構成として。
| 層 | 使ったもの |
|---|---|
| アプリ | Expo SDK 55 / React Native 0.83 / expo-router(typed routes) |
| 状態 | Zustand + TanStack Query(AsyncStorageで永続化) |
| バックエンド | Supabase(Postgres + RLS + Realtime + Storage + Edge Functions) |
| リアルタイム | postgres_changes(受信の確実性)+ broadcast(プレゼンス・共同編集) |
| ネイティブ | 自作Expoモジュール(Swift / AVFoundation)で合成・圧縮 |
| 通話 | react-native-callkeep + VoIP Push + CallKit |
Supabaseは個人開発のバックエンドとして本当に強くて、RLSの無限再帰(自己参照するポリシーが相互参照して死ぬやつ)をis_conversation_participant()みたいなヘルパー関数に逃がす、みたいな所だけ気をつければ、認証・DB・リアルタイム・関数・ストレージが全部1枚で済みます。
デザインは、いわゆるY2K的な蛍光色を全部やめて、朱色(#FF5C34)一色と和紙っぽいオフホワイト、抹茶のセージ系でまとめました。通知の少ない静かなアプリにしたかったので、見た目もうるさくしたくなかった。
正直な反省
実は前にも別アプリで個人開発の記事を書いたのですが、あまり読まれませんでした。理由はたぶん明確で、「作りました!使ってください!」が前に出すぎて、技術として読む価値が薄かったからだと思います。
なので今回は、自分が本当に時間を溶かした座標系の話を中心に書きました。同じようにAVFoundationで「複数動画を1枚に合成して書き出したい」人が、top-down / bottom-upのところでこの記事に辿り着いてくれたら、それで元は取れたと思っています。
そして同時に、Onliの体験のコアもそこにあります。「投稿しないと見えない」という小さな摩擦と、1日の終わりに全員が1本の縦動画になるという小さなご褒美。この2つだけで、近い友達との連絡が驚くほど続くようになりました(少なくとも自分のグループでは)。
試してもらえると
3〜4人の、本当に気を許せる相手と入れてみてほしいアプリです。無料・広告なし・おすすめタイムラインなし。今日の朝・昼・夜の3枠を、お互い埋めていくだけ。
📱 App Store: https://apps.apple.com/it/app/onli/id6771083321
質問や「ここどう実装したの?」があればコメントで。特にAVFoundationの合成まわりとVoIPは、需要があれば単体で詳しく書きます。
