第 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 抽关键帧确认画面。

图文25 分钟

课程目录第 2 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

写一段 7 秒中文片头,让 lint 与 check 帮你修好字体和节奏

本课对照的官方页面:数据属性 、GSAP 动画 、变量 、Lint 规则 、CLI 参考中的 lint / check / snapshot 。测试环境同课时 1:Node.js v22.21.1、FFmpeg 7.1.5、hyperframes 0.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,用变量换一版文案,再从官方组件库装一个字幕条进来。