倒计时视频
制作一个能在精确时刻结束的倒计时。整个方案只有一个核心思想:从帧(或单调时间戳)推导剩余时间,然后格式化和动画化该数字——永远不要使用 setInterval 进行递减。涵盖直播“即将开始”画面、活动/促销倒计时以及循环背景。
何时使用
- 直播“即将开始”/“马上回来”画面,搭配 5–10 分钟计时器。
- 产品发布和闪购倒计时(到目标日期的 dd:hh:mm:ss 格式;短时紧迫感用 mm:ss)。
- 任何需要数字在精确帧上归零的动画计数器。
唯一规则:从时钟驱动数字,绝不使用定时器
使用 setInterval(fn, 1000) 的倒计时会漂移——回调触发时间 至少 延迟 1000ms,永远不精确,且当标签页被节流或渲染帧缓慢时会累积误差。常见报告是每分钟丢失约 1 秒。对于渲染视频和实时网页,解决方案相同:将剩余时间作为权威时钟的纯函数来计算,这样迟到的帧会在下一帧自行修正,而不是累积误差。
| 上下文 | 权威时钟 | 剩余时间 |
|---|---|---|
| Remotion 渲染 | useCurrentFrame() | total − frame/fps |
| Web(实时页面) | performance.now() 或 Date.now() | targetMs − now |
| 固定日期 | Date.now() | targetTimestamp − Date.now() |
永远不要将剩余时间存入状态并递减它。每帧都重新计算。
帧精确倒计时(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 — 使用 ceil 使“1”显示完整一秒
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,使视频时长与倒计时完全一致。
网页计时器(实时页面,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 与 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(或结束状态保持);无漂移,无差一秒错误
# - 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(无漂移/无差一秒)。 - 在每个检查帧中,计时器+品牌块都在 9:16 安全区域内。
- 仅由帧驱动——无
setInterval/Date.now()/ 定时器;使用tabular-nums保持布局稳定。 - 交付的
durationInSeconds(不仅仅是defaultProps)正确渲染;MP4 已编码,GIF 可选。
参考文件
references/countdown-cookbook.md——完整可运行的 Remotion<Countdown>(可配置时长,mm:ss / dd:hh:mm:ss,结束状态保持),带时区处理的固定日期倒计时网页组件,“即将开始”直播画面布局(品牌块+社交链接+循环渐变背景),以及无缝循环背景模式。references/digit-flip.md——两个确定性的数字动画组件:里程表卷轴(由帧驱动的垂直 0–9 条带)和翻牌(上半部分折叠)效果,以及各自适合发布、促销还是直播场景的说明。