HyperFrames 两阶段相机光标跟踪:为 OpenMontage 打造打字驱动的确定性虚拟镜头
导读
本文讲解 OpenMontage 仓库中 HyperFrames 动画技能库的原子规则 camera-cursor-tracking(完整规则原文)。当一条水平增长的文案(不断键入的搜索框、渐次出现的超长 URL)需要始终完整地呈现在镜头内时,这条规则通过"静态锚定 → 追踪跟随"的两阶段虚拟相机,把视口锁在一个持续移动的焦点(光标、高亮块、最后一个键入字形)上。读完后,你将掌握:两阶段偏移量的数学表达与无缝切换、可在多 worker 并行渲染下保持确定性的 GSAP 时间轴写法、全套可调参数(从光标几何到相机缓动)的取值策略,以及如何将这条规则与同仓库的上下文光标、离散文本序列等规则组合成多阶段镜头。
HyperFrames 是 OpenMontage 内置的"用 HTML 渲染视频"技能体系——一条可渲染合成(composition)就是一份用 data-* 属性声明时序的 HTML 文件(见 hyperframes 入口技能)。camera-cursor-tracking 属于 hyperframes-animation 下的原子运动规则(rules),与多阶段相机、坐标定点缩放等规则并列,可被诸如 cursor-ui-demo 蓝图 这样的多阶段场景模板引用为"主相机"。
它是如何工作的:两阶段虚拟相机
核心思路是把两个"空间"彻底分离:
- World Space(世界空间)——完整的目标元素,包含全部内容;
- Screen Space(屏幕空间)——视口(viewport),观众实际看到的窗口。
相机在两个阶段之间切换:
- Phase 1(静态):世界容器停在固定的初始偏移
INITIAL_OFFSET上,相机不动。这样在追踪开始前,观众视线已被锚定在当前构图,镜头不会"抢戏"。 - Phase 2(追踪):当焦点(光标、高亮、最后键入的字形)越过目标屏幕位置——例如可配置的比例
CURSOR_TARGET_FRACTION × viewportWidth——世界容器向左平移(x: -<delta>),把焦点始终"钉"在那个屏幕位置上。
相位边界处的连续数学
两阶段的交接在数学上是连续的——追踪启动的瞬间,世界位置恰好等于静态阶段该有的值,因此过渡无跳变。代码中使用的分段形式是:
finalWorldX = Math.min(INITIAL_OFFSET, trackingOffset)
其中 INITIAL_OFFSET 是静态阶段的值;trackingOffset 是让焦点保持在 CURSOR_TARGET_FRACTION × viewportWidth 所需的任何位移。只要焦点还没有长过目标屏幕 X,trackingOffset 就会大于 INITIAL_OFFSET(它是一个"不那么负"的数),于是 Math.min 返回静态值;一旦焦点越过目标,trackingOffset 反超 INITIAL_OFFSET,追踪接管。
强调:这条规则的作者明确警告不要改成
if (typingProgress > threshold)的硬分支——硬分支会在相位边界造成可见的镜头跳动(见下文"关键约束")。
合成(Composition)骨架:HTML 与 data-* 属性
场景根元素声明了整个合成的时序元数据。data-composition-id 是关键——它必须与动画注册表键(window.__timelines["tracking-scene"])一致;data-duration 是渲染时长而非时间轴长度(data-attributes 参考)。
<div
class="scene"
id="tracking-scene"
data-composition-id="tracking-scene"
data-start="0"
data-duration="5"
data-track-index="0"
>
<div class="viewport">
<div class="world">
<div class="search-bar">
<span class="text" id="reveal-text">{phrase}</span><span class="cursor">|</span>
</div>
</div>
</div>
</div>
注意结构层级:scene(合成根)→ viewport(视口,负责裁剪)→ world(可平移的世界容器)→ search-bar → text(可增长文本)与 cursor(内联跟随的光标块)。在整个 HyperFrames 生态中,可见的计时元素通常还需要 class="clip" 标记,否则运行时会让它全程保持可见而忽略 data-start/data-duration(本规则示例以动画驱动文本显现,故用时间轴 maxWidth 揭示而非 clip 窗口控制)。
布局:CSS(hero-frame 布局)
CSS 的核心分工是:.viewport 用 overflow: hidden 裁剪出"镜头",.world 保持 white-space: nowrap 让文案单行排布以配合相机数学,transform: translateX(0) 预留出 GSAP 的动画空间。
.scene {
position: relative;
width: 100%;
height: 100%;
}
.viewport {
position: absolute;
inset: 0;
overflow: hidden; /* clip the world content */
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: VIEWPORT_PAD_LEFT; /* "left margin" — variation: left-aligned init */
}
.world {
display: flex;
align-items: center;
white-space: nowrap; /* keep text on one line for camera-tracking */
transform: translateX(0); /* GSAP will animate this */
}
.search-bar {
font-family: {font};
font-size: BAR_FONT_SIZE;
font-weight: BAR_FONT_WEIGHT;
color: {textColor};
letter-spacing: BAR_LETTER_SPACING;
}
.search-bar .text {
/* Width grows as more characters reveal */
display: inline-block;
overflow: hidden;
vertical-align: bottom;
}
.search-bar .cursor {
display: inline-block;
width: CURSOR_WIDTH;
margin-left: CURSOR_GAP;
background: {accentColor};
height: CURSOR_HEIGHT_EM;
vertical-align: bottom;
/* No `animation: blink` CSS keyframe here — HF renders by seeking a paused
timeline, and CSS animation clocks are NOT synced to that seek. A CSS
blink will flicker non-deterministically. Drive cursor blink as a finite
yoyo tween on the GSAP timeline instead — see GSAP Timeline section. */
}
两个要点:
- CSS 注释本身就是约束文档——
.cursor上禁止 CSS@keyframes闪烁动画,因为 HyperFrames 通过"寻找已暂停时间轴"的方式渲染每一帧,CSS 动画时钟与该 seek 不同步,必然闪屏。闪烁必须由 GSAP 时间轴上的有限 yoyo tween 驱动。 VIEWPORT_PAD_LEFT必须与.viewport的padding-left严格一致,否则相机数学会漂移(见下文参数表)。
驱动:GSAP 时间轴
整条规则围绕一条暂停的 GSAP 时间轴展开,且必须在脚本解析期同步构建:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Pre-measure target text width to compute tracking distance.
// Build the timeline SYNCHRONOUSLY — see Critical Constraints for why
// a fonts.ready gate causes worker-race flicker.
const textEl = document.getElementById("reveal-text");
const fullText = textEl.textContent;
const targetCursorScreenX = CURSOR_TARGET_FRACTION * VIEWPORT_WIDTH;
const fullWidth = textEl.scrollWidth; // total text width after full reveal
const trackingDelta = Math.max(0, VIEWPORT_PAD_LEFT + fullWidth - targetCursorScreenX);
// Phase 1 — text reveals progressively; camera holds.
// Reveal via max-width tween (no layout-property tweens on width/left/top).
tl.fromTo(
".search-bar .text",
{ maxWidth: 0 },
{
maxWidth: fullWidth,
duration: REVEAL_DUR,
ease: "none", // linear typing rate
},
REVEAL_START,
);
// Phase 2 — camera tracks. Begin BEFORE full reveal so the boundary feels
// continuous (text is still revealing as camera starts moving). The
// Math.min(INITIAL_OFFSET, trackingOffset) formulation makes the handoff
// mathematically continuous; see How It Works.
tl.to(
".world",
{
x: -trackingDelta,
duration: TRACK_DUR,
ease: "power2.inOut",
},
TRACK_START,
);
// Cursor blink — GSAP-driven (NEVER CSS @keyframes infinite, which doesn't
// sync with HF's seek-by-frame). Finite yoyo, repeats computed from scene
// length so blinks land deterministically across frames.
const blinkRepeats = Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1;
tl.to(
".search-bar .cursor",
{
opacity: 0,
duration: BLINK_HALF_PERIOD,
ease: "steps(1)", // hard on/off, no fade
yoyo: true,
repeat: blinkRepeats,
},
0,
);
window.__timelines["tracking-scene"] = tl;
</script>
时间轴的三段编排解读
- 文本揭示(Phase 1):
fromTo把.text的maxWidth从 0 动画到fullWidth。选择maxWidth而非width是有意为之——这是动画属性白名单内的布局安全做法;ease: "none"保证每字符击键节奏线性,任何缓动都会扭曲打字手感。文本宽度用textEl.scrollWidth预测量,而不是"字符数 × 字号"。 - 相机追踪(Phase 2):
tl.to(".world", { x: -trackingDelta, ...})在TRACK_START位置插入。位移量在开头一次性算出:trackingDelta = Math.max(0, VIEWPORT_PAD_LEFT + fullWidth - targetCursorScreenX),即"文本总宽度超出目标光标屏位的那一部分"。追踪刻意在揭示完成之前开始(TRACK_START < REVEAL_START + REVEAL_DUR),让相位切换像一次自然的跟拍。 - 光标闪烁:有限 yoyo tween。
repeat次数由场景长度推导:Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1,保证闪烁能确定性地覆盖整个场景长度、不跨帧错乱;ease: "steps(1)"提供硬开关无淡变。
这段时间轴完整符合 HyperFrames 的动画运行时契约(determinism-rules):暂停时间轴、注册键与 data-composition-id 一致、绝不在 async/Promise/setTimeout 中构建、使用有限 repeat 而非 repeat: -1。
变体(Variations)
三种已验证的配置模式覆盖了最常见的取景需求:
- 居中 → 中心追踪(Centered → Center-Tracked):设置
.viewport { justify-content: center; padding: 0; }。一旦焦点越过中线(CURSOR_TARGET_FRACTION = 0.5)相机即开始追踪。 - 左对齐 → 右追踪(Left-Aligned → Right-Tracked):即上文完整示例。最适合内容一开始就超过视口宽度的场景(如整段 URL 渐现)。
- 连续打字驱动器(Continuous typing driver):用
onUpdate打字时钟(charsTyped = Math.floor(progress))替代maxWidthtween,并在每一帧用measureNodeWidth驱动光标屏幕 X。当键入文本在场景别处被消费时(例如被父级条带的相机偏移量读取)必须使用此变体。
参数选择指南
规则附带了完整的参数取值表,逐条给出取值范围、效果与约束。下面是整理后的速查表,可作为落地时的默认参考:
| 参数 | 范围 / 取值 | 效果与约束 |
|---|---|---|
VIEWPORT_PAD_LEFT |
0 → 约视口宽度的 10% | Phase 1 锚定 X。0 贴左边缘;越大越像居中的 hero 取景。必须与 .viewport 的 padding-left 一致,否则相机数学漂移 |
VIEWPORT_WIDTH |
合成的 data-width(CSS 像素) |
必须等于场景根的 data-width;永不被 tween |
CURSOR_TARGET_FRACTION |
0.5(中心追踪)→ 0.75(右倾,光标后有更多已揭示文本) | 取值越低,画面内已揭示文本越少;越高则追踪启动越晚 |
BAR_FONT_SIZE |
约为视口高度的 8%–12% | 低于约 6% 时读感会从"电影化元素"降级为"UI 控件" |
BAR_FONT_WEIGHT |
离散;400(中性演示文本)/ 700(hero/标题取景) | 按叙事层级选择 |
BAR_LETTER_SPACING |
轻微负值(更紧致、更电影化)→ 0(默认) | 条内字距微调 |
CURSOR_WIDTH |
约 4–10 px(1080p) | 更细读作"输入光标";更粗读作块状插入符 |
CURSOR_GAP |
数 px 呼吸空间 | 不要超过光标宽度,否则视觉上会脱开 |
CURSOR_HEIGHT_EM |
0.85–1.0 | 匹配所打字形的视觉高度 |
REVEAL_START |
通常为 0 | 若前面还有其他相位,需 ≥ 该相位结束 + 小缓冲 |
REVEAL_DUR |
随字符数缩放,单字符节奏约 0.05–0.15 s | 必须早于 SCENE_DURATION 结束,最好与追踪相位略有重叠 |
TRACK_START |
通常早于揭示完成;若焦点在 t=0 已越过目标,可等于 REVEAL_START |
TRACK_START < REVEAL_START + REVEAL_DUR 保证平滑交接 |
TRACK_DUR |
0.8–2.0 s | 低于 0.5 s 读作跳动(snap);超过 2.5 s 拖沓 |
SCENE_DURATION |
必须等于场景根的 data-duration |
喂给闪烁 repeat 计数;不匹配会导致闪烁提前截断或越界 |
BLINK_HALF_PERIOD |
0.2–0.4 s(0.3 s 最接近自然光标闪烁) | 派生值 Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1 必须 ≥ 0 |
| 缓动选择 | 离散 | 相机平移用 power2.inOut/power3.inOut 营造电影化落定;避免 back.out(过冲像 UI 弹跳而非相机)。揭示用 "none" 线性;闪烁用 "steps(1)" 硬开关 |
关键原则
- 用
getBoundingClientRect()/ 探针节点测量宽度,不要用"字符数 × font-size"推算——比例字体各字形宽度不同。 - 世界容器
.world必须white-space: nowrap——文本必须保持单行,相机数学才成立。 - 预分配世界宽度:把
maxWidth设为满目标宽度,避免 tween 中途出现布局位移。 - 相机用缓动而非直线运动(
power2.inOut/power3.inOut),才像自然运镜。 - 用缓动近似弹簧感:GSAP 没有内建弹簧,但
back.out(${BOUNCE_FACTOR})或power4.out可以近似"落定回弹"的手感。
关键约束(Critical Constraints)
这部分是全规则最容易踩坑的地方,值得逐条展开:
- 时间轴必须同步构建、禁止
fonts.ready门控。HyperFrames 在并行 worker 中渲染各帧,每个 worker 都是全新浏览器。如果把时间轴构建包在document.fonts.ready.then(...)里,部分 worker 会在 Promise 解析前就 seek 帧,找不到已注册的时间轴 → 该帧以 CSS 初始态渲染(例如max-width: 0导致文本为空);另一些 worker 则正常 → 空/满文本之间出现可见闪烁。应在脚本解析期就把window.__timelines[id] = tl注册好——即便字体尚未加载。相机数学能容忍回退字体测量造成的几个百分点的宽度误差,但 worker 竞争闪烁不可接受。 - 如果确实需要精确的字体加载后测量:在 tween 的
onUpdate里重测(对逐帧 seek 仍然是确定性的),而不是用 Promise 门控;或者在@font-face上设font-display: block,强制浏览器在绘制任何文本前等待字体。 - 时间轴必须暂停:
gsap.timeline({ paused: true }),永远不调用tl.play()。这正对应 determinism-rules 中"动画状态必须可由 HyperFrames 时间 seek"的核心要求——渲染器没有"播放"概念,每帧都是对时间值的一次全新 seek。 - 注册表键 =
data-composition-id:window.__timelines["tracking-scene"]必须与场景根匹配。 - 相位边界处数学必须连续:追踪启动瞬间世界的
x必须等于静态阶段的值。Math.min(INITIAL_OFFSET, trackingOffset)保证这一点;不要改成if (typingProgress > threshold)硬分支,否则镜头会可见跳变。 - 光标内联而非绝对定位:光标必须是文本的兄弟元素(
inline-block),随文本流自然跟随——绝对定位会与相机数学错位。 .viewport必须overflow: hidden:裁剪世界左边缘在平移出画时的泄漏。- 光标闪烁必须由 GSAP 驱动,禁止 CSS
@keyframes ... infinite:HyperFrames 靠 seek 暂停时间轴渲染,CSS 动画时钟与 seek 不同步,任何 CSS 驱动的闪烁都会跨帧不确定地闪屏。闪烁永远是暂停时间轴上的一次有限 yoyo tween,repeat 次数由场景长度计算得出。
这些约束与 hyperframes-animation SKILL 中"布局常量预计算、空间运动只用 GSAP transform 别名(x/y/scale/rotation)、布局属性(width/height/top/left)不得用于 layout 变更 tween"的动画工艺约定一脉相承——本规则用 maxWidth + x 正是对这一约定的具体落实。
组合与配对技能
这条规则设计成可与同目录其他原子规则自由组装:
- context-sensitive-cursor.md:在打字过程中按文本片段切换光标颜色/样式——把"相机追光标"与"光标随片段变色"叠起来,就是完整的终端/搜索框叙事镜头;
- discrete-text-sequence.md:非线性文本揭示(错别字、整段补充、停顿、思考间隙),与"连续打字 + 相机追踪"的平滑型形成互补。
技能级配对则对应 HyperFrames 的领域技能分工:
/hyperframes-animation—— 时间轴 + tween API(原子规则本库);/hyperframes-core—— 合成接线与data-*属性(data-attributes.md);/hyperframes-cli—— 用hyperframes lint校验注册表键与时长(lint-validate-inspect)。
在蓝图中的角色
从 cursor-ui-demo 蓝图 的引用可以印证它的典型用法:当需要一个"相机伺服到光标所触之处"的产品演示(Product_Intro / Key_Feature 变体)时,camera-cursor-tracking 被列为主相机(primary)——"这种对光标的相机伺服,正是该蓝图区别于无手势相机滚动与静态设备窗口导览的核心"。它通常与 viewport-change(横移/竖移/推拉的形式实现)、multi-phase-camera(把追逐拆成离散交互节拍)以及 coordinate-target-zoom(推近到被操作区域)一起组成完整的"chase"运镜链。
落地检查:用 CLI 验证合成
在完成 HTML/CSS/GSAP 组装后,HyperFrames 提供一套"先检查后渲染"的纪律(hyperframes-cli),对运动密集的场景尤其适用:
npx hyperframes lint # 静态检查:data-composition-id 缺失、同轨重叠、未注册时间轴
npx hyperframes validate # headless Chrome 运行时检查:console 错误、网络失败、WCAG 对比度
npx hyperframes inspect --samples 15 # 时间轴布局扫描(默认 9 个采样点)
npx hyperframes snapshot --frames 10 # 输出关键帧 PNG 供肉眼核对
lint 能直接抓到本文反复强调的"注册表键与 data-composition-id 不匹配""时长不一致"这类缺陷;inspect 支持 *.motion.json sidecar 做运动意图断言(如 appearsBy、staysInFrame),是"渲染 ≠ 预览"类 bug 最接近自动化的代理检查。对打字揭示这类逐帧 seek 场景,先跑一轮 lint + snapshot 再看 PNG,通常比直接渲染视频更快收敛。
小结
camera-cursor-tracking 给出了一个把"镜头语言"落实为确定性的两阶段数学模型的完整范例:静态锚定稳住视线、Math.min 让追踪接手时无缝衔接、暂停时间轴 + 同步注册保证多 worker 并行渲染下逐帧可复现。它的可迁移价值在于——任何"水平增长元素 + 移动焦点"的镜头问题(搜索框打字、URL 渐现、跑马灯焦点跟拍),都能复用这套 HTML/CSS/GSAP 骨架与参数调优方法,再通过上下文光标变色与离散文本序列等规则把单镜头扩成多段落的多阶段叙事。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00