1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自宅 NAS と Vercel で月 $0 の動画ツールを作った話

1
Posted at

やったこと

研究関係でブラウザ上で動画にクリックして自動計算するツールを作った。
完全クライアントサイド + 自宅 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 で動くもの、結構作れます。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?