やったこと
研究関係でブラウザ上で動画にクリックして自動計算するツールを作った。
完全クライアントサイド + 自宅 NAS で動かして月額 $0。
Stack: Next.js 16 (App Router) / React 19 / Tailwind 4 / Vercel Hobby / Supabase Free / Synology DS223j / Cloudflare Tunnel。
ハマったところ 3 つ
① Vercel + Supabase Free の cold-start 500
Supabase 無料枠は無アクセス時に DB が pause されるので、たまに初回クエリがコケて API が 500 になる。ユーザー体験的にやばい。
1 段だけリトライするユーティリティを書いて全部のクエリに巻いたら、ほぼ消えた。
// src/lib/db-retry.ts
export async function withDbRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
try {
return await fn();
} catch (e) {
console.warn(`${label} 初回失敗、500ms 後に再試行:`, e);
await new Promise(r => setTimeout(r, 500));
return fn();
}
}
呼び出し側はラップするだけ:
const profile = await withDbRetry(
() => prisma.userProfile.findUnique({ where: { clerkUserId: userId } }),
"userProfile.findUnique",
);
正常時のオーバーヘッドはゼロ。失敗したときだけ 500ms 待って 1 回再試行。
これで「取得に失敗しました」のサポート問い合わせがほぼ消えた。
おまけ: 部分失敗を許容する
複数サブシステムを叩く API は 1 個落ちると全 500 になりがち。
個別 try/catch + フラグで返すと UI 側で握れる。
let nasHealth = null;
if (mode === "nas") {
try { nasHealth = await getNasHealth(); }
catch (e) { console.error("NAS health 失敗:", e); }
}
return NextResponse.json({ videos, nasHealth, dbFailed });
UI はフラグ見てバナー出すだけ:
{data.dbFailed && (
<Banner>DB 接続が不安定です <button onClick={() => load()}>再読み込み</button></Banner>
)}
② Cloudflare Free の 100MB リクエスト上限
400MB の動画を直接 PUT すると弾かれる。
ブラウザ側で 45MB チャンクに分割 → サーバ側で結合、で解決。
なぜ 45MB かというと、multipart のヘッダ込みで 100MB を超えないため(80MB だとギリギリ落ちる、これでハマった)。
const CHUNK = 45 * 1024 * 1024;
const { uploadId } = await fetch(`${nas}/chunk/start`, { method: "POST" }).then(r => r.json());
for (let i = 0; i < Math.ceil(file.size / CHUNK); i++) {
const chunk = file.slice(i * CHUNK, (i + 1) * CHUNK);
await fetch(`${nas}/chunk/upload`, {
method: "POST",
body: chunk,
headers: { "X-Upload-Id": uploadId, "X-Chunk-Index": String(i) },
});
setProgress((i + 1) / totalChunks * 100);
}
await fetch(`${nas}/chunk/complete`, { method: "POST", body: JSON.stringify({ uploadId }) });
NAS 側(FastAPI)は start で upload_id 発行 → upload でチャンク保存 → complete で結合、の 3 エンドポイント。
無料 CDN の制限をコード側で吸収するパターン、応用効くのでオススメ。
③ Canvas のクリック vs ドラッグ問題
座標記録モードと、領域をドラッグで動かすモードを 1 つの canvas で両立したかった。
素直に onClick だけだと、ドラッグしたつもりがクリックとして座標が打たれる。
移動距離 5px 未満のときだけクリックとして扱う、いわゆる drag threshold pattern で解決。
const downPos = useRef<{ x: number; y: number } | null>(null);
const onMouseDown = (e) => {
downPos.current = { x: e.clientX, y: e.clientY };
};
const onMouseUp = (e) => {
const moved = downPos.current
? Math.hypot(e.clientX - downPos.current.x, e.clientY - downPos.current.y) >= 5
: false;
downPos.current = null;
if (moved) return; // ドラッグ扱い、クリック処理はしない
if (mode === "tracking") recordPoint(getCoords(e));
if (mode === "calibration") setCalibrationPoint(getCoords(e));
};
これで 1 つのキャンバスに mode state を持たせて 4 モード切替(tracking / zone-drag / zone-resize / calibration)が成立する。地味だけど UI 統合の鍵。
計算は全部クライアント
「速度の平均/最大/最小、滞在時間、停止検出」って一見サーバ集計したくなるけど、手動クリックだと点が 1000 個もないので useMemo で十分。
function buildSegments(points) {
return points.slice(1).flatMap((p, i) => {
const prev = points[i];
const dt = p.time - prev.time;
if (dt <= 0) return [];
return [{ dt, distPx: Math.hypot(p.x - prev.x, p.y - prev.y), speedPx: ... }];
});
}
// 速度が閾値未満の連続区間 ≥2s を「停止」として検出
function detectFreezing(segs, threshold) {
const eps = [];
let start = null;
for (const seg of segs) {
if (seg.speedPx < threshold) start ??= seg.startTime;
else if (start !== null) {
if (seg.startTime - start >= 2.0) eps.push({ start, dur: seg.startTime - start });
start = null;
}
}
return eps;
}
サーバ要らない = デプロイ反映を待たずに改善できるのが地味に最高。
ローディング画面はちゃんと作る
animate-pulse のグレー矩形 6 枚はもう古い。
カードの形を予告する skeleton + 斜めシマー + stagger でリップル、にしたら体感が全然違う。
{[...Array(6)].map((_, i) => (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-xl border bg-gradient-to-br from-white/[0.025] to-transparent p-3.5 relative overflow-hidden"
>
<div className="flex gap-2.5">
<Skeleton className="w-9 h-9 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-2 w-1/2 opacity-70" />
</div>
</div>
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background: "linear-gradient(110deg, transparent 30%, rgba(99,102,241,0.06) 50%, transparent 70%)",
backgroundSize: "300% 100%",
animation: "shimmer 2.4s ease-in-out infinite",
animationDelay: `${i * 0.15}s`,
}}
/>
</motion.div>
))}
ロード中だけど何が来るか想像できる、というだけで離脱しなくなる。
まとめ
| 課題 | 解 |
|---|---|
| Vercel cold-start で 500 |
withDbRetry 1 行 |
| サブシステム障害で全 API 死亡 | 各 fetch 個別 try/catch + フラグ |
| Cloudflare 100MB 制限 | 45MB チャンク分割 |
| クリック vs ドラッグ | 5px threshold |
| サーバ集計 |
useMemo + 純関数 |
| ダサ skeleton | 構造化 + 斜めシマー + stagger |
無料枠 + 自宅 NAS で動くもの、結構作れます。