动感字体 (Kinetic Typography)
制作视频,让文字本身成为画面。一个短语落地、充满画框、被猛烈冲击,然后被下一个短语替代,伴随节奏。它应该给人大胆、快速且富有音乐感的感觉,就像一个移动的海报。
选项
| 选项 | 默认值 | 说明 |
|---|---|---|
| format | 竖屏 1080×1920 | 或横屏 1920×1080,正方形 1080×1080 |
| duration | 10–20 秒 | 匹配画外音或歌曲段落 |
| tone | punchy | calm(0.4秒的滑动和淡入淡出,较少翻转),punchy(默认),chaotic(全大写、猛击、抖动,每隔一个短语翻转一次) |
| rhythm | 固定节奏 | 或画外音单词时间,或音乐节拍 |
简报(规划前回答)
- 观众应该记住的一句话是什么?
- 将剧本拆分为1–4个单词的短语;每个短语标记一个主词。
- 能量峰值在哪里(获得颜色翻转的那个短语)?
- 结尾锁定帧是什么(标题、行动号召、用户名)?
可读性表(与节拍列表一同编写)
对于每个节拍,写出观众必须理解的一件事(“机器人不会飞”、“缓存未命中”)以及它保持可读的时间。一次只读一件事。文字停留时间:标签至少停留0.8秒,一个句子完全显示后每个单词约0.3秒。如果文字在时间内放不下,则裁词,而不是加快速度。
运动规则
- 运动是因果的:屏幕上的每个动作都有原因(点击、落下、数据包到达),并且下一个动作随之而来。
- 为整个视频选择一个主要运动方向并保持一致。使用其他方向来表示含义:向上表示成功或结论,拉远表示“这是整体画面”。
- 场景的退出为下一个场景的进入做准备:相同的轴、相同的方向、相似的速度。在变化之间携带一个对象,而不是交叉淡入淡出。
- 图表、图表或文字上不要有无关的填充动画(浮动、呼吸、摆动)。如果什么都不该动,就保持静止;一次有意的镜头漂移就足够了。
- 缓动:平滑的ease-out用于进入(
power2.out到power4.out),较快的ease-in用于退出(约为进入时间的75%)。进入时间最多0.8秒。整个组的交错最多0.5秒。过冲(back.out)仅用于小的弹出效果;不要使用弹性或弹跳。 - 重要时刻一次只移动一个东西;眼睛只能跟踪一个。
- 在回报前添加0.3–0.75秒的静止节拍。
风格规则(始终遵守)
这些定义了此风格;无论颜色或文字如何都要保持。
- 文字占画框宽度的50–80%,因为小字看起来像字幕,而不是视觉主体。
- 一次只显示一个想法:1–4个单词,最多3行。其他所有内容都已离开。
- 每个单词都通过一个动作到达(猛击、滑动、拉伸、蒙版揭示、翻转)。没有东西只是淡入。
- 变化落在节奏上:每0.25–0.7秒出现一个新单词或动作,关键词在节拍或重读音节上。
- 对比度极端:接近黑色的文字配接近白色的背景,或反之,再加上仅用于关键词的一种强调色。
- 每个短语有一个主词:最大、强调色或运动方式与其余不同。
默认值(可自由更改)
字体、颜色、文字、时间和背景。如果用户提供了品牌字体或颜色,则以他们的为准。优先级:用户的品牌或规格说明 > 明确要求 > 这些默认值。
- 背景
#111111,文字#f4f1ea,强调色#ff4d2e。每短语可在深色或浅色背景上切换以增强能量。 - 显示字体:厚重的窄体(Anton、Bebas Neue 或 Archivo Black),主词全大写。辅助字体:厚重的无衬线字体(Inter 800)用于小的连接词。
- 字号:9:16中主词180–320像素;小词60–90像素。
设置(一次性)
node -v # 需要22+
ffmpeg -version
npx hyperframes init my-video --non-interactive --resolution portrait && cd my-video # landscape for 16:9
在已有的空文件夹内使用 npx hyperframes init . --non-interactive --resolution portrait,然后用下面的模板替换生成的 index.html。
构建前决定
在编写代码之前,将这些反馈给用户,一行即可。
- 宽高比: 9:16 (1080×1920) 或 16:9 (1920×1080)。默认是9:16。
- 文字和时间: 将剧本拆分为1–4个单词的短语。如果有画外音,从其单词时间戳获取时间(
npx hyperframes transcribe voice.wav)。如果没有,给每个短语0.6–1.4秒,给主词更长时间。 - 节奏来源: 语音(落在重读音节上)、音乐(落在节拍上;
npx hyperframes beats music.mp3)或固定节奏(例如120 BPM = 每0.5秒一个节拍)。 - 每个短语的主词 以及它使用的动作。
| 需求 | HyperFrames + GSAP (默认) | Remotion |
|---|---|---|
| 单词和字母动作、蒙版、拉伸 | 是 | 是 |
| 语音或节拍数据的时机 | 是 (labels) | 是 (frames) |
| 仅需Node和FFmpeg即可运行 | 是 | 是 |
| 许可证 | 免费 | 最多3人免费,以上收费 |
动作词汇表
| 动作 | 方式 (GSAP) | 用于 |
|---|---|---|
| Slam | from {scale: 2.6, opacity: 0},expo.out,0.22秒,外加6像素屏幕抖动持续0.1秒 | 主词 |
| Slide stack | 在 overflow:hidden 蒙版内 from {yPercent: 110},交错0.06秒,power4.out 0.35秒 | 列表、双行短语 |
| Stretch | scaleY: 1.6 → 1,transformOrigin: bottom,back.out(3) 0.3秒 | 强调、动词 |
| Letter cascade | 字母 from {y: -120, rotation: -20, opacity: 0},交错0.025秒,back.out(2) | 名称、标题 |
| Flip swap | 旧词 rotationX: 90 退出 (0.12秒),新词 rotationX: -90 → 0 进入 (0.18秒) | X变成Y |
| Split | 单词两半向 x: ±140 滑开以显示中间较小的单词 | 对比 |
| Exit | yPercent: -110 或 scale: 0.7, opacity: 0,0.15秒,power2.in | 每个短语离开 |
退出要快(0.12–0.18秒),进入要比你想象的更敏捷(0.18–0.35秒)。每个短语落地后至少保持0.35秒,以便阅读。
让它引人注目
- 充满画框。 根据画框宽度确定字号,而不是根据字号确定尺寸。250像素以上的窄体字体才符合风格。
- 颜色翻转: 在2–3个关键短语上,背景在深色和浅色(或强调色)之间切换,作为节拍上的硬切。
- 纹理和深度: 微妙的颗粒叠加层、柔和的阴影或主词后面的偏移副本(强调色,偏移10–14像素),以及偶尔出现的巨大淡化文字背景。
- 镜头动感: 整个舞台缓慢的2–4%缩放漂移,在主词命中时 punch-in(缩放1.06持续0.1秒),以及猛击时的6–10像素抖动。
- 带形状的文字: 拉出的下划线、在单词后面弹出的圆圈、箭头。使用强调色,扁平。
- 以锁定帧结束: 标题或行动号召,完全构图,保持1–1.5秒,匹配开头帧以便循环。
- 质量门: 如果任何截图显示居中且背景纯白的小字,说明还没完成。
构建:模板
<!doctype html><html><head><meta charset="UTF-8">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Inter:wght@800&display=swap">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<style>
html,body{margin:0;width:1080px;height:1920px;overflow:hidden;background:#111}
#root{width:100%;height:100%;position:relative;overflow:hidden}
#stage{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
.phrase{position:absolute;left:60px;right:120px;top:270px;bottom:670px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:0}
.line{overflow:hidden;line-height:.92;padding:0 8px}
.w{display:inline-block;font-family:Anton,sans-serif;text-transform:uppercase;color:#f4f1ea;white-space:nowrap}
.hero{color:#ff4d2e}
.flip .w{color:#fff}.flip .hero{color:#111}
.small{font-family:Inter,system-ui,sans-serif;font-weight:800;text-transform:none}
#bg{position:absolute;inset:0;background:#111}
</style></head>
<body><div id="root" data-composition-id="main" data-start="0" data-duration="8" data-width="1080" data-height="1920">
<div id="bg"></div><div id="stage"></div></div>
<script>
// [time, lines, moveName, heroWordIndex, flipBackground?] -- edit this list; times in seconds
const PHRASES = [
[0.2, ["WORDS"], "slam", 0],
[1.0, ["can", "MOVE"], "stack", 1],
[2.0, ["LIKE", "MUSIC"], "stretch", 1, true],
[3.2, ["one", "idea"], "stack", 1],
[4.2, ["AT A", "TIME"], "slam", 1],
[5.4, ["KINETIC", "TYPE"], "cascade", 1],
];
const END = 8, W = 1080 - 180;
// measure text only after the web fonts load, then build and register the timeline
document.fonts.ready.then(() => {
const tl = gsap.timeline({ paused: true });
const stage = document.getElementById("stage");
PHRASES.forEach(([t, lines, move, hero, flip], i) => {
const p = document.createElement("div"); p.className = "phrase" + (flip ? " flip" : ""); stage.appendChild(p);
const words = lines.map((txt, k) => {
const line = document.createElement("div"); line.className = "line";
const w = document.createElement("span"); w.className = "w" + (k === hero ? " hero" : "") + (txt === txt.toLowerCase() && k !== hero ? " small" : "");
w.textContent = txt; line.appendChild(w); p.appendChild(line);
w.style.fontSize = "100px"; const fit = Math.min(420, (100 * W) / w.getBoundingClientRect().width); // fill the width
w.style.fontSize = (w.classList.contains("small") ? Math.min(fit, 110) : fit) + "px";
return w;
});
gsap.set(p, { autoAlpha: 0 });
tl.set(p, { autoAlpha: 1 }, t);
if (move === "slam") tl.from(words, { scale: 2.6, opacity: 0, duration: 0.22, ease: "expo.out", stagger: 0.08 }, t)
.fromTo("#stage", { x: -10 }, { x: 10, duration: 0.04, yoyo: true, repeat: 3, ease: "none" }, t + 0.18).set("#stage", { x: 0 }, t + 0.34);
if (move === "stack") tl.from(words, { yPercent: 110, duration: 0.35, ease: "power4.out", stagger: 0.07 }, t);
if (move === "stretch") tl.from(words, { scaleY: 1.7, opacity: 0, transformOrigin: "50% 100%", duration: 0.3, ease: "back.out(3)", stagger: 0.08 }, t);
if (move === "cascade") words.forEach((w, k) => {
const letters = Array.from(w.textContent); w.textContent = "";
letters.forEach((c) => { const l = document.createElement("span"); l.style.display = "inline-block"; l.textContent = c; w.appendChild(l); });
tl.from(w.children, { y: -140, rotation: -20, opacity: 0, duration: 0.4, ease: "back.out(2)", stagger: 0.03 }, t + k * 0.15); });
const next = PHRASES[i + 1] ? PHRASES[i + 1][0] : null;
if (flip) tl.set("#bg", { background: "#ff4d2e" }, t).set("#bg", { background: "#111" }, next || END); // colour flip on a key phrase
if (next) tl.to(words, { yPercent: -110, opacity: 0, duration: 0.15, ease: "power2.in", stagger: 0.03 }, next - 0.17)
.set(p, { autoAlpha: 0 }, next);
});
tl.fromTo("#root", { scale: 1 }, { scale: 1.04, duration: END, ease: "none" }, 0); // slow drift
tl.set({}, {}, END);
window.__timelines["main"] = tl;
});
</script></body></html>
编辑 PHRASES 来更改单词和时间。通过从动作词汇表中添加一个 if (move === ...) 分支来添加动作。如果有画外音,将 <audio> 元素放入根元素,并从其转录中获取短语时间。
暴露给用户的参数
aspect · phrases (time, lines, move, hero) · fontDisplay · fontSupport · colors (bg, text, accent) · tempo or audio · flipPhrases (where the background flips) · endLockup。
避免
- 淡入和缓慢漂移作为主要动作。这看起来像幻灯片。
- 屏幕上一次显示超过4个单词或3行。
- 多种强调色。只使用一种强调色,且仅用于主词。
- 使用纤细或轻量字体,以及主词的脚本字体。
- 文字位于9:16画框的底部670像素或顶部270像素内(平台UI会覆盖它们)。
- 随机或每次渲染不同的变化。每个值都必须来自时间线。
失败模式
| 表现 | 原因 | 修复 |
|---|---|---|
| 看起来像幻灯片 | 淡入淡出、长时间停留、每次动作都相同 | 从词汇表中变化动作;进入时间低于0.35秒;退出时间0.15秒 |
| 像是字幕,而不是设计 | 文字太小,居中在空白的空间中 | 根据画框宽度调整字号;使用窄体字体;250像素以上 |
| 混乱、不可读 | 一次出现太多文字或动作 | 屏幕上只显示一个短语;一个主词;落地后保持0.35秒 |
| 节拍不准 | 短语时间靠猜测 | 使用转录或节拍时间;命中提前2–3帧 |
| 文字被蒙版裁剪 | 字体行高过紧 | line-height 为0.92–1.0,并在 .line 上添加内边距;检查截图 |
| 渲染中字体错误 | 字体未加载 | 在 <head> 中使用Google Fonts <link>;检查截图是否显示窄体字体 |
最终渲染前验证
npx hyperframes check
npx hyperframes snapshot --at <每个短语落地后0.3秒的时间点,外加一个退出过程中的时间点>
npx hyperframes render -o renders/<name>.mp4
ffprobe -v error -show_entries stream=width,height,r_frame_rate:format=duration -of csv=p=0 renders/<name>.mp4
查看每个截图和 snapshots/contact-sheet.jpg,并确认:
- 每个短语占宽度的50–80%,并且在手机屏幕上可读
- 每个短语一个主词,使用强调色或不同的动作
- 没有任何两个短语在任何帧上重叠
- 所有文字都在对应宽高比的安全区域内
- 结尾锁定帧至少保持1秒,并匹配开场
- 尺寸和持续时间符合要求
最终渲染前自评
对截图册进行1–10分的评分,评估项:钩子(第一秒)、手机屏幕上的可读性、动作质量、构图、准确性,以及“静音测试”(观众能否在无声音的情况下用一句话总结它?)。只修复得分为7或以下的项目,重新截图,并重复直到所有项目都在8分或以上。如果可以,让一个独立的只读审查者进行评分,这样你就不是在给自己的作品打分。
| 不要 | 改为 |
|---|---|
| 在两个繁忙布局之间交叉淡入淡出 | 退出旧内容,然后引入新内容,或者携带一个对象穿过 |
| 一次让多个东西移动 | 交错,一次只关注一个 |
...
[为翻译截断]