第 2 课
写一段 7 秒中文片头,让 lint 与 check 帮你修好字体和节奏
用数据属性排好标题、副标题、进度条与网址的出场,用 fromTo 写动画并声明三个变量;按 lint 的 font_family_without_font_face 报错下载 Noto Sans SC 并写 @font-face;按 check 的 first_frame_empty 与 line_unread 两条提示改三行代码,直到 Check passed;用 snapshot 抽关键帧确认画面。
课程目录第 2 / 3 课
学习位置仅保存在当前浏览器,有效期 180 天。
写一段 7 秒中文片头,让 lint 与 check 帮你修好字体和节奏
本课对照的官方页面:数据属性 、GSAP 动画 、变量 、Lint 规则 、CLI 参考中的 lint / check / snapshot 。测试环境同课时 1:Node.js v22.21.1、FFmpeg 7.1.5、
hyperframes0.9.26。所有「对照结果」均为真实输出节选,超长行已折行。
这一课从零写一段 7 秒的中文片头:前 4 秒是主标题、副标题和一条绿色进度条依次出现,随后整组淡出,最后 3 秒是网址落版。故意先写一版「看起来没问题」的代码,让 lint 和 check 帮你找出两类真问题:中文字体没有打包,以及首帧空白、结尾停留太短这样的动效节奏问题。
第 1 步:新建项目并写入第一版 index.html
cd ~/hf-lab
hyperframes init kun-intro --non-interactive --skip-skills
cd kun-intro
用编辑器打开 index.html,把全部内容替换成下面这份(可直接复制):
<!doctype html>
<html
lang="zh-CN"
data-composition-variables='[
{"id":"title","type":"string","label":"主标题","default":"昆仑学堂"},
{"id":"subtitle","type":"string","label":"副标题","default":"用 HTML 写一段视频"},
{"id":"accent","type":"color","label":"强调色","default":"#22c55e"}
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<style>
:root {
--ground: #0b1020;
--ink: #f8fafc;
--font-display: "Noto Sans SC", sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1920px; height: 1080px;
overflow: hidden; background: var(--ground);
}
#root {
position: relative; width: 100%; height: 100%;
font-family: var(--font-display); color: var(--ink);
}
#title, #subtitle, #bar, #outro {
position: absolute; left: 0; width: 1920px; text-align: center;
}
#title { top: 360px; font-size: 140px; font-weight: 700; }
#subtitle { top: 560px; font-size: 56px; }
#bar {
top: 680px; left: 760px; width: 400px; height: 12px;
border-radius: 6px; background: var(--accent);
transform-origin: left center;
}
#outro { top: 480px; font-size: 72px; font-weight: 700; color: var(--accent); }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="6"
data-width="1920" data-height="1080">
<h1 id="title" class="clip" data-start="0" data-duration="4"
data-track-index="0" data-var-text="title">昆仑学堂</h1>
<p id="subtitle" class="clip" data-start="0" data-duration="4"
data-track-index="1" data-var-text="subtitle">用 HTML 写一段视频</p>
<div id="bar" class="clip" data-start="0" data-duration="4"
data-track-index="2"></div>
<p id="outro" class="clip" data-start="title" data-duration="2"
data-track-index="0">www.kun.net</p>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { opacity: 0, y: 60 },
{ opacity: 1, y: 0, duration: 0.8, ease: "power3.out" }, 0.2);
tl.fromTo("#subtitle", { opacity: 0, y: 30 },
{ opacity: 0.85, y: 0, duration: 0.6, ease: "power2.out" }, 0.7);
tl.fromTo("#bar", { scaleX: 0 },
{ scaleX: 1, duration: 0.8, ease: "power2.inOut" }, 1.2);
tl.to(["#title", "#subtitle", "#bar"], { opacity: 0, duration: 0.4 }, 3.6);
tl.fromTo("#outro", { opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1, duration: 0.5, ease: "back.out(2)" }, 4.1);
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
</script>
</body>
</html>
这份代码里每一处都对应官方文档里的一条规则:
| 写法 | 作用 |
|---|---|
根元素 data-duration="6" | 整段视频 6 秒,渲染长度以它为准 |
data-start="0" data-duration="4" | 标题、副标题、进度条在 0 到 4 秒出现 |
data-start="title" | 网址在 #title 结束时出现,即第 4 秒;也可以写 title + 0.5 留间隔 |
data-track-index | 只决定编辑器里显示在哪一行,不决定上下层 |
gsap.timeline({ paused: true }) | 时间线必须暂停,由 HyperFrames 逐帧跳转 |
fromTo 而不是 from | 官方建议同时写清起点和终点,随机跳帧时更稳 |
window.__timelines["main"] | 注册名必须等于根元素的 data-composition-id |
data-composition-variables 与 data-var-text | 把标题、副标题、强调色声明成变量,第 3 课用它出第二版 |
var(--accent) | 颜色类变量会变成同名 CSS 变量,可直接在样式里用 |
注意布局样式写在 #title 这些 id 上,而不是写在通用的 .clip 类上。第 3 课挂进来的官方组件内部也用了 .clip,如果你给 .clip 写了定位样式,会把组件一起挤跑(本课测试时真的遇到过:字幕条整块不见了)。
第 2 步:lint 报错,中文字体没有打包
hyperframes lint
对照结果(路径已省略):
◆ Linting kun-intro/index.html
✗ […/index.html:27:9] font_family_without_font_face: Font family used without @font-face
declaration: noto sans sc. These are not in the auto-resolved font list, so the renderer
cannot supply them automatically. Text will fall back to a generic font, producing
incorrect typography in the video.
Fix: Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); }
for each font family, pointing to the captured .woff2 files, or switch to a Google Fonts
family. An OS font such as PingFang SC or Hiragino Sans renders locally from the
installed copy, but a cloud render has no copy and refuses it.
◇ 1 error(s), 0 warning(s)
意思是:你写了 Noto Sans SC,但渲染器既不认识它,也找不到字体文件,最终会退回一个默认字体,中文可能变成别的字形甚至方块。修法是把字体文件放进项目,用 @font-face 声明。Noto Sans SC 是 Google 与 Adobe 发布的开源字体(SIL OFL 许可),官方仓库 notofonts/noto-cjk 提供简体中文子集:
mkdir -p assets/fonts
curl -L -o assets/fonts/NotoSansSC-Regular.otf \
https://github.com/notofonts/noto-cjk/raw/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf
curl -L -o assets/fonts/NotoSansSC-Bold.otf \
https://github.com/notofonts/noto-cjk/raw/main/Sans/SubsetOTF/SC/NotoSansSC-Bold.otf
ls -lh assets/fonts
对照结果:
-rw-r--r-- 1 box box 8.2M Nov 10 00:13 NotoSansSC-Bold.otf
-rw-r--r-- 1 box box 8.0M Nov 10 00:13 NotoSansSC-Regular.otf
然后在 <style> 的最前面加两段 @font-face:
@font-face {
font-family: "Noto Sans SC"; font-weight: 400;
src: url("assets/fonts/NotoSansSC-Regular.otf") format("opentype");
}
@font-face {
font-family: "Noto Sans SC"; font-weight: 700;
src: url("assets/fonts/NotoSansSC-Bold.otf") format("opentype");
}
再跑一次:
hyperframes lint
对照结果:
◆ Linting kun-intro/index.html
◇ 0 errors, 0 warnings
第 3 步:用 check 做浏览器体检
lint 只读 HTML;check 会真正打开无头 Chrome,按时间轴抽样,检查运行时报错、版式重叠、动效、文字对比度和「成片手感」。
hyperframes check
对照结果(节选):
[HyperFrames] Not inlining "assets/fonts/NotoSansSC-Regular.otf" (7.9 MB exceeds the 2 MB
inline limit). The bundle may not be self-contained.
Lint
◇ 0 errors, 0 warnings
Runtime
◇ 0 errors, 0 warnings
Layout
◇ 0 issues across 9 sample(s)
Motion
◇ 0 errors, 0 warnings
Contrast
◇ 7/7 text checks pass WCAG AA
Craft
⚠ first_frame_empty: Frame 0 shows only the background, and the first frame is the
thumbnail on social players: have the title or key image already on screen at 0 s instead
of entering from nothing; see references/review.md.
index.html [data-composition-id] t=0s
⚠ line_unread: The film's last line "www.kun.net" (3 words) is on screen 1.83 s (±0.01 s),
under the 2.00 s a closing line needs: hold it about 0.5 s + 0.3 s a word and at least 2 s
before the film ends; see references/motion.md.
index.html #outro t=4.172s
0 error(s), 2 warning(s), 0 info(s)
Audio
ℹ audio_none: The film has no sound: no clip in it plays audio.
◇ Check passed
结论是「通过但有两条警告」,两条都很实用:
first_frame_empty:第 0 秒画面只有背景。很多平台把第一帧当封面,所以标题最好一开始就在画面上,而不是从透明淡入line_unread:结尾网址只停留了约 1.83 秒,低于建议的 2 秒,观众来不及读
开头那条 Not inlining 提示是说字体文件超过 2 MB,不会被内联进打包后的单文件;本机渲染不受影响,只有要把整个项目打成一个自包含文件时才需要处理(可以先用字体子集工具只保留用到的字)。
第 4 步:按提示改三处,直到 check 全绿
只需要改三行:总时长 6 秒改 7 秒,网址停留 2 秒改 3 秒,标题从「透明淡入」改成「一开始就在、轻微缩放落定」:
- <div id="root" data-composition-id="main" data-start="0" data-duration="6"
+ <div id="root" data-composition-id="main" data-start="0" data-duration="7"
- <p id="outro" class="clip" data-start="title" data-duration="2"
+ <p id="outro" class="clip" data-start="title" data-duration="3"
- tl.fromTo("#title", { opacity: 0, y: 60 },
- { opacity: 1, y: 0, duration: 0.8, ease: "power3.out" }, 0.2);
+ tl.fromTo("#title", { scale: 1.08 },
+ { scale: 1, duration: 0.8, ease: "power3.out" }, 0);
hyperframes check
对照结果(节选):
Craft
◇ 0 errors, 0 warnings
Audio
ℹ audio_none: The film has no sound: no clip in it plays audio.
0 error(s), 0 warning(s), 1 info(s)
◇ Check passed
剩下的 audio_none 只是信息提示(这段片头本来就没配乐),不算问题。
第 5 步:抽关键帧确认画面
hyperframes snapshot --at 0,1.5,3,5
对照结果(节选):
◆ Capturing 4 frames at [0s, 1.5s, 3s, 5s] from kun-intro
Fonts: 2 loaded
Note: added an end-of-timeline frame at 6.79s.
[hyperframes] snapshot: frame 1/5 at 0s
…
◇ 5 snapshots saved to …/kun-intro/.hyperframes/artifacts/snapshots/472e3360-…
…/contact-sheet.jpg (grid view for AI review)
Fonts: 2 loaded 说明两个字重都加载成功。打开 contact-sheet.jpg 对照:0 秒只有大标题「昆仑学堂」;1.5 秒副标题出现、绿色进度条正在伸长;3 秒进度条拉满;5 秒和 6.79 秒是绿色的 www.kun.net。如果中文显示成方块或明显不是黑体,回到第 2 步检查字体路径。
常见坑
- 把
@font-face的url写成以/开头的绝对路径会找不到文件;路径从项目根目录算,写assets/fonts/… - 依赖本机已装的苹方、微软雅黑也能在自己电脑上渲染出来,但
lint会报错,换一台机器或上云渲染就会变字体;正式项目请把字体文件放进项目 - 时间线忘了
{ paused: true }或注册名和data-composition-id不一致,预览时可能看着正常,逐帧渲染时动画却不动 - 不要在时间线里调用视频的
play()或改currentTime,媒体播放交给 HyperFrames 按数据属性控制 - 改了代码不必每次完整渲染,先
lint,再check,最后用snapshot看关键帧,最省时间
小结
你写出了一段 7 秒中文片头,用数据属性排好了出场顺序,用暂停的 GSAP 时间线做了动画,并按 lint 的报错把中文字体打包进项目,按 check 的两条手感提示修掉了首帧空白和结尾太短。下一课把它渲染成 MP4,用变量换一版文案,再从官方组件库装一个字幕条进来。
