はじめに
「動画をスワイプで次々見られる、TikTokみたいなサイトが欲しい」
そんな思いつきから始まったのが、今回作った匿名ショート動画共有サイト SeaTubes です。

構成はいたってシンプルで、サーバーは立てず、静的なHTMLファイル数枚だけで完結させています。
- フロント: 素のHTML / CSS / JavaScript(フレームワークなし)
- ホスティング: GitHub Pages
- データベース: Supabase
- 動画ストレージ: Cloudinary
この記事では、Claude(Anthropic社のAIアシスタント)に指示を出しながらこのサイトを組み上げていった過程を、実際にぶつかった失敗も含めて紹介します。「動画アップロード先をどこにするか」でかなり迷走したので、その顛末も正直に書きます。
できあがったもの
-
index.html… TikTok風の縦スクロール動画フィード -
upload.html… 動画投稿フォーム -
mypage.html… 自分が投稿した動画・いいねした動画の一覧 -
termsofservice.html… 利用規約ページ
主な機能は以下の通りです。
- スワイプ(スクロール)で次の動画へ、URLに
?videos=1のようなパラメータが自動で同期される - ハートを押すといいねが保存され、次回アクセス時も「いいね済み」状態が復元される(Cookie + Supabase)
- 動画ごとのいいね数を表示
- フルHD(1920×1080)を超える動画は投稿をブロック
- 投稿時に利用規約への同意が必須
- 投稿者のIPアドレスをうっすら記録(不正投稿対策)
実際のサイトは https://seatubes.40channel.com からどなたでも見ていただけます。
1. まずは骨組みを作る
最初のお願いはこんな感じでした。
モダンなSeaTubesという動画共有サイトを作って。動画はurlはCloudinaryのUnsigned機能でやります。動画のurlの保存や、題名、概要欄の保存にはsupabaseを使用してください。動画へのアクセスはseatubes.github.io?videos=1などの方法で行い、動画は基本ショート動画で、1分未満です。
ポイントは以下の3つでした。
- 動画そのものはCloudinaryの署名なし(Unsigned)アップロード機能を使い、ブラウザから直接クラウドにアップロードする
- 動画のメタデータ(URL・タイトル・概要)はSupabaseのテーブルに保存する
- 表示側はTikTokのような縦スワイプのフィードにする
Supabase側のテーブルはこんな感じでシンプルに始めました。
create table videos (
id bigint generated always as identity primary key,
title text not null,
description text,
video_url text not null,
created_at timestamptz default now()
);
alter table videos enable row level security;
create policy "Public read access" on videos
for select using (true);
create policy "Public insert access" on videos
for insert with check (true);
フィード側はIntersectionObserverでスクロール位置を監視し、画面に映っている動画だけを自動再生する作りにしました。
const observer = new IntersectionObserver((changes) => {
changes.forEach(change => {
const vid = change.target.querySelector('video');
if(change.isIntersecting && change.intersectionRatio > 0.6){
vid.play();
} else {
vid.pause();
}
});
}, { threshold: [0, 0.6, 1] });
これでとりあえず「TikTok風に動画がスワイプで切り替わる」体験はできました。
2. 動画が謎に拡大されるバグ
最初のバージョンをPCで開いてみると、動画が異様に拡大されて表示される不具合が発生しました。
原因は、フィードの幅を width: 100vw にしていたことでした。スマホでは画面幅=ビューポート幅なので問題になりませんが、PCの広い画面で 100vw を指定すると、想定より広い範囲に object-fit: cover で動画を引き伸ばしてしまっていたのです。
.slide{
width: 100vw; /* PCの広い画面でも横幅いっぱいに広がってしまう */
}
.slide{
width: 100%; /* 親要素(#feed)の幅に追従させる */
}
@media (min-width: 640px){
#feed{
max-width: 430px; /* PCではスマホのような縦長カードとして中央に表示 */
height: 92vh;
border-radius: 24px;
}
}
vw(ビューポート幅)と %(親要素の幅)の違いを見落としていたのが原因でした。PC・スマホ両方でレイアウトを確認する重要性を再認識しました。
3. アップロード先探しの迷走記
ここからが今回一番の山場です。動画のアップロード先を、実は4回変更しています。
3-1. Cloudinary(最初の選択)
最初はご要望通りCloudinaryのUnsigned Uploadで実装しました。特に問題なく動作していました。
3-2. 「無料の別サービスを使いたい」→ Catboxへ
ある時、「Catboxという無料ファイルホスティングのAPIに変えたい」という要望があり、切り替えました。
const formData = new FormData();
formData.append('reqtype', 'fileupload');
formData.append('fileToUpload', file);
const res = await fetch('https://catbox.moe/user/api.php', {
method: 'POST',
body: formData
});
ところが、ブラウザで実行すると即座にこんなエラーが出ました。
Access to XMLHttpRequest at 'https://catbox.moe/user/api.php'
from origin 'http://127.0.0.1:5500' has been blocked by CORS policy
CatboxのAPIはブラウザからの直接リクエストを許可していない(CORSヘッダーを返さない)ことが判明しました。
3-3. 中継サーバーを立てて回避を試みる
「じゃあSupabase Edge Function(サーバーレス関数)を中継役にすればいいのでは」と考え、ブラウザ→Edge Function→Catboxという構成に変更しました。
Deno.serve(async (req) => {
const incomingForm = await req.formData();
const file = incomingForm.get("file");
const catboxForm = new FormData();
catboxForm.append("reqtype", "fileupload");
catboxForm.append("fileToUpload", file, file.name);
const catboxRes = await fetch("https://catbox.moe/user/api.php", {
method: "POST",
body: catboxForm,
});
const resultText = (await catboxRes.text()).trim();
return new Response(JSON.stringify({ url: resultText }), {
headers: { "Access-Control-Allow-Origin": "*" }
});
});
理屈上はこれでCORSを回避できるはずでした。ところが実行すると、今度は 502エラー + Invalid uploader というエラーが返ってきました。
どうやらCatboxは、クラウド事業者のサーバー(データセンターのIPアドレス)からのアップロードそのものを弾いているようでした。ヘッダーを偽装しても解決しない、サービス側のポリシーの壁です。
3-4. Bytescaleを試すも、無料枠の実態が判明
次に試したのが動画・画像アップロード専門のBytescaleでした。公式SDKがあり、ブラウザから直接CORS対応でアップロードできる点は快適でした。
const uploadManager = new Bytescale.UploadManager({ apiKey: BYTESCALE_PUBLIC_KEY });
const { fileUrl } = await uploadManager.upload({
data: file,
onProgress: ({ progress }) => console.log(progress)
});
しかし調べてみると、Bytescaleは実質「無料トライアル」であり、永続的に無料で使えるプランではないことが分かりました。個人の趣味サイトを永久無料で運用したいという目的には合わないと判断し、ここで撤退。
3-5. 結局、Cloudinaryに出戻り
一周回って、最初に使っていたCloudinaryに戻しました。実績があり、無料枠(月25クレジット)の範囲であれば安定して動作します。
教訓: 「無料っぽく見えるサービス」ほど、実際に触ってみないと制約(CORS・クラウドIPブロック・トライアル期限)が分からない。結局、実績があり公式にブラウザ直アップロードをサポートしているサービスが一番安全、というありがちな結論に落ち着きました。
4. ブラウザが落ちる問題(メモリリーク)
機能が増えてきたころ、動画が10本、20本と増えると ブラウザ自体が重くなり、最悪落ちる という報告がありました。
原因は明白で、フィードに表示する全ての動画分の <video> タグを、最初から一気にDOM上へ並べていたことでした。動画は音声・映像のデコードにそれなりのメモリを使うため、これでは当然重くなります。
対策として、「現在表示中のスライド + 前後1つ」だけ <video> 要素を実体化し、画面から離れたらDOMごと破棄する仮想化という手法を導入しました。
const PRELOAD_RADIUS = 1; // 前後1つまで許容
function updateActiveWindow(activeIndex){
entries.forEach((entry, i) => {
const withinWindow = Math.abs(i - activeIndex) <= PRELOAD_RADIUS;
if(withinWindow){
mountVideo(entry); // <video>を生成してDOMに挿入
} else {
unmountVideo(entry); // <video>を破棄してメモリを解放
}
});
}
function unmountVideo(entry){
if(!entry.vid) return;
entry.vid.pause();
entry.vid.removeAttribute('src');
entry.vid.load(); // これでブラウザ内部のバッファも解放される
entry.vid = null;
}
これで、動画が何本あっても同時にメモリを使うのは最大3本分だけになり、動作が軽くなりました。
同じタイミングで、「背景にぼかした動画を敷いて没入感を出す」という凝った演出も原因の一つだったため、思い切って廃止しました。軽さのためにエフェクトを削る判断も大事だと実感しました。
5. ブラウザ内で動画を圧縮しようとして、また壁にぶつかる
「フルHD超えの動画は自動で1080pにリサイズ・圧縮してほしい」という要望もありました。
サーバーを持たない構成なので、ffmpeg.wasm(ブラウザ内で動くFFmpeg)を使う方針にしました。
const ffmpeg = new FFmpeg();
await ffmpeg.load({ coreURL, wasmURL });
await ffmpeg.writeFile(inputName, fileData);
await ffmpeg.exec([
'-i', inputName,
'-vf', `scale=${targetWidth}:${targetHeight}`,
'-c:v', 'libx264',
'-crf', '26',
outputName
]);
一応動くには動いたのですが、そもそも「GitHub Pagesは特殊なHTTPヘッダー(COOP/COEP)を設定できないため、ffmpeg.wasmのマルチスレッド版が使えない」という制約があり、シングルスレッド版でしのぐ必要がありました。処理も遅く、実用面での不安が残りました。
最終的に「圧縮機能は諦め、フルHD超えの動画はそもそも投稿をブロックする」というシンプルなバリデーションに切り替えました。
video.addEventListener('loadedmetadata', () => {
if(video.videoWidth > 1920 || video.videoHeight > 1080){
isOverResolution = true;
showError('フルHD(1920×1080)までの動画のみ投稿できます。');
}
});
「技術的にできること」と「今の構成で無理なく安定して提供できること」は別だと痛感した場面でした。
6. いいね機能とCookieによる永続化
いいねボタンを押したら、リロードしても状態が保持されるようにしたいという要望に対応しました。ログイン機能を作るのは大掛かりなので、匿名のCookie IDで代用しています。
function getOrCreateUserId(){
let uid = getCookie('st_uid');
if(!uid){
uid = crypto.randomUUID();
setCookie('st_uid', uid, 365); // 365日保持
}
return uid;
}
Supabase側は likes テーブルを用意し、video_id と user_id(Cookieの値)の組み合わせに一意制約を張ることで、同じ人が同じ動画に何度もいいねできないようにしています。
create table likes (
id bigint generated always as identity primary key,
video_id bigint not null references videos(id) on delete cascade,
user_id text not null,
created_at timestamptz default now(),
unique (video_id, user_id)
);
この仕組みのおかげで、mypage.html を作る際も「Cookieに紐づく投稿・いいね一覧」を簡単に実装できました。ログイン機能なしで「マイページ」的なものが作れるのは、匿名SNSならではの面白いところだと思います。
7. 最終的な構成まとめ
| 役割 | 使用サービス | 備考 |
|---|---|---|
| ホスティング | GitHub Pages | 静的ファイルのみ |
| 動画ストレージ | Cloudinary | Unsigned Upload |
| データベース | Supabase | REST API を直接叩く構成 |
| いいね機能 | Supabase + Cookie | 匿名UUIDで永続化 |
| IPアドレス記録 | ipify.org | 簡易的な不正対策 |
サーバーサイドのコードは一切書いておらず、静的HTML+外部APIの組み合わせだけで一通りの機能が実現できました。
おわりに
「無料で動くサービスを探す」という工程が一番時間がかかった、というのが正直な感想です。CORSのブロック、クラウドIPの拒否、無料トライアルの罠——机上では分からない制約に何度もぶつかりました。
一方で、「動かなかったら別の手段に切り替える」という判断を繰り返しながら組み上げていくプロセス自体は、AIと対話しながらだと非常にスピーディーに進められました。興味のある方は、ぜひ同じような構成でショート動画サイトを作ってみてください。