カウントダウンビデオ は何をしますか?
フレーム精度で正確に終了するカウントダウンを作成します。全体のコンセプトはひとつ:フレーム(または単調なタイムスタンプ)から残り時間を導き出し、その数値をフォーマットしてアニメーション表示する——決して setInterval でカウントダウンしてはいけません。ライブ配信の「まもなく開始」画面、ローンチ/セールのカウントダウン、ループ背景をカバーします。
使用する場面
- 「まもなく開始/すぐ戻ります」ライブ配信画面(5~10分タイマー)
- ローンチやフラッシュセールのカウントダウン(目標日時まで dd:hh:mm:ss、緊急性を出すため mm:ss)
- 数値が正確なフレームでゼロにならなければならないアニメーションカウンター
唯一のルール:数値はクロックから取得し、ティックカウンターでは決して行わない
setInterval(fn, 1000) によるカウントダウンはドリフトします——コールバックは少なくとも 1000 ms 後に発火し、正確にはならず、タブがスロットリングされたりレンダーフレームが遅いと蓄積します。1分あたり約1秒のロスが報告されています。レンダリング動画でもライブWebでも解決策は同じです。信頼できるクロックから残り時間を純粋関数として計算することで、遅れたフレームは次のフレームで自己修正され、誤差が蓄積しません。
| コンテキスト | 信頼できるクロック | 残り時間 |
|---|---|---|
| Remotion レンダリング | useCurrentFrame() | total − frame/fps |
| Web(ライブページ) | performance.now() または Date.now() | targetMs − now |
| 固定日時に向けて | Date.now() | targetTimestamp − Date.now() |
残り時間を state に保存してデクリメントしてはいけません。毎フレーム再計算してください。
フレーム精度のカウントダウン(Remotion → MP4)
useCurrentFrame() は決定論的です:フレーム N は常に正確に N/fps 秒の時点でレンダリングされます。合計から差し引けば、タイマーは設計上ドリフトフリーになります。
import { useCurrentFrame, useVideoConfig } from "remotion";
export const Countdown: React.FC<{ durationInSeconds: number }> = ({
durationInSeconds,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 残り秒数、0でクランプ——「1」はその1秒間表示されるよう ceil
const elapsed = frame / fps;
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
return <div style={{ fontVariantNumeric: "tabular-nums" }}>{formatMMSS(remaining)}</div>;
};
durationInSeconds はプロパティであり、定数ではありません。同じコンポジションで、入力を変えるだけで5分のストリームタイマーにも30秒のセールタイマーにもなります。コンポジションの durationInFrames を durationInSeconds * fps に設定し、動画の長さをカウントダウンと正確に一致させてください。
Webタイマー(ライブページ、requestAnimationFrame)
ライブページの場合は、performance.now() を使って rAF ループを駆動し、表示秒が変わったときだけ再描画します(1Hzの数値に60回/秒の処理は不要):
const start = performance.now();
let lastShown = -1;
function tick(now) {
const remaining = Math.max(0, Math.ceil(duration - (now - start) / 1000));
if (remaining !== lastShown) { render(remaining); lastShown = remaining; }
if (remaining > 0) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
固定日時までのカウントダウンも同じ考え方で Date.now() を使います:Math.max(0, target - Date.now())。完全な日時ターゲット版とタイムゾーン処理は references/countdown-cookbook.md を参照してください。
時間のフォーマット
残り秒数の整数を除算+剰余で各フィールドに分解し、ゼロ埋めします。持続時間に合ったフォーマットを選んでください——分だけで十分なときに 00d 00h 05m のように表示してはいけません。
const pad = (n) => String(n).padStart(2, "0");
const formatMMSS = (s) => `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
const formatDHMS = (s) => {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60), sec = s % 60;
return `${pad(d)}:${pad(h)}:${pad(m)}:${pad(sec)}`;
};
| フォーマット | 使用場面 | フィールドを非表示にする条件 |
|---|---|---|
mm:ss | 配信「まもなく開始」、短いタイマー(<1h) | — |
hh:mm:ss | フラッシュセール、同日イベント | dd が0のとき非表示 |
dd:hh:mm:ss | ローンチ/複数日カウントダウン | 先頭のゼロ単位を非表示にして見やすく |
常に font-variant-numeric: tabular-nums(または等幅数字フォント)を設定し、数字の幅を一定にしてレイアウトが揺れないようにしてください。
数字ロール/フリップトランジション
数字のフラットな入れ替えは安っぽく見えます。オドメーターのようなローリング表現がカウントダウンを引き立てます。各桁を0–9の縦ストリップとしてレンダリングし、-digit × digitHeight だけ平行移動させます。テキスト内容ではなく位置をアニメーションさせることで、数字の変化がポップではなくグライドになります。
.reel { height: 1em; overflow: hidden; }
.reel .col { transition: transform .45s cubic-bezier(.22,1,.36,1); }
/* col は0..9を積み重ねて保持;-value em だけシフト */
Remotion では、CSS トランジションではなくフレームからストリップオフセットを駆動します(決定論的にするため)。フリップカードスタイル(上半分が下半分の上に折りたたまれる)と桁ごとのリールの両方について、実行可能なコンポーネントとともに references/digit-flip.md で詳述しています。
ループ背景
ストリーム画面は数分間保持されるため、背景はシームレスにループする必要があります。タイムラインを均等に分割できる周期(例:loopFrames を周期とするグラデーションやパーティクルのドリフト)でモーションを構築し、frame % loopFrames で読み取ることで、ラップ時に目に見える継ぎ目ができないようにします。低コントラストで動きを遅くしてください——数字が主役であり、背景は雰囲気です。レシピは references/countdown-cookbook.md にあります。
出力チェックリスト
- 残り時間は毎フレーム
useCurrentFrame()/performance.now()から再計算される——setIntervalによるデクリメントはしない。 - タイマーは意図した正確なフレームで
00:00になる;durationInFrames === durationInSeconds × fps。 - 持続時間は入力プロパティであり、ハードコードされた定数ではない。
- 数字は
tabular-numsを使用;数字が変わってもレイアウトがずれない。 - フォーマットは持続時間に合っている(mm:ss vs dd:hh:mm:ss);先頭のゼロ単位は邪魔な場合は削除。
- 背景ループ周期がタイムラインを均等に分割し、ラップに継ぎ目がない。
納品と検証(レンダリング静止画 → MP4)
パッケージ化されたヘルパー(
scripts/):scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.pngで静止画をタイル表示し、scripts/probe-mp4.sh out.mp4 [WxH] [fps]でエンコードを確認します。scripts/README.md参照。
カウントダウンは Remotion コンポジション(<Composition> + zod schema + defaultProps、durationInSeconds はプロパティ)として出荷されます——タイマーは useCurrentFrame() の純粋関数であり、setInterval / Date.now() / Math.random() は使いません。durationInFrames = durationInSeconds * fps を設定します。納品物 = out/*.mp4 + プロジェクト。デフォルトは 9:16 縦長(1080×1920)です。
検証ループ — 静止画レンダリング → 検査 → エンコード。 フレーム精度が肝心なので、エンコード前に正確なフレームで数字の値を確認してください。
# 開始/中間/終了の静止画 — 出荷プロパティ(実際にレンダリングする持続時間)を使用
npx remotion still Countdown out/f-start.png --frame=0 --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-mid.png --frame=N --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-end.png --frame=L --props='{"durationInSeconds":300}' # L = durationInFrames-1
# 各 PNG を検査 — 数字は frame/fps の計算を正確に読み取っていること:
# - フレーム N で、表示 = ceil(durationInSeconds - N/fps)(例:300s @ 30fps、フレーム4500 → 150s → "02:30")
# - 最終フレームは 00:00(または終了状態の保持)を読み取る;ドリフトなし、1秒のずれなし
# - tabular-nums:フレーム間でレイアウトが揺れない
# - 9:16 セーフエリア:タイマー+ブランドブロックが上部約12%、下部約20-35%のUIおよび右側のアクションレールにかからない
npx remotion render Countdown out/countdown.mp4 --props='{"durationInSeconds":300}' # 数字が確認できたらエンコード
npx remotion render Countdown out/demo.gif --codec=gif # README 用のプルーフクリップ
npx remotion compositions で durationInFrames/fps を確認し、確認したい任意の秒数に対応するフレームを計算してください。
完了前に確認すること:
- 静止画がフレーム0、中間、最終で問題なくレンダリングされる——エラーなし。
- フレームNでの数字の値が
ceil(durationInSeconds - N/fps)と正確に一致する;最終フレームは意図したフレームで 00:00 に達する(ドリフト/1秒のずれなし)。 - タイマーとブランドがすべての確認フレームで9:16セーフエリア内に収まっている。
- フレーム駆動のみ——
setInterval/Date.now()/ タイマーなし;tabular-numsでレイアウトが固定されている。 - 出荷プロパティ
durationInSeconds(defaultPropsだけでなく)で正しくレンダリングされる;MP4 エンコード済み、GIF は任意。
参考ファイル
references/countdown-cookbook.md— 完全な実行可能 Remotion<Countdown>(設定可能な持続時間、mm:ss / dd:hh:mm:ss、終了状態保持)、タイムゾーン処理付きの固定日時カウントダウンWebコンポーネント、「まもなく開始」ストリーム画面レイアウト(ブランドブロック+ソーシャルハンドル+ループグラデーション背景)、およびシームレスループ背景パターン。references/digit-flip.md— 2つの決定論的数字アニメーションコンポーネント:オドメーターリール(フレーム駆動の縦0–9ストリップ)とフリップカード(上半分の折り畳み)エフェクト、およびそれぞれがローンチ/セール/ストリームのどの場面に適しているか。