2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発で「投稿しないと友達のも見えない」アプリを出した話

2
Last updated at Posted at 2026-06-06

個人開発で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

ok.png


なぜ作ったのか

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は、需要があれば単体で詳しく書きます。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?