Repository navigation
Description
Summary
When a composition's inline <script> throws, the render fails with:
HyperFrames runtime failed: TypeError: Cannot read properties of undefined (reading 'getBoundingClientRect').
Check the composition's scripts and window.__timelines entries; if they look right, report this as a HyperFrames bug.
The message is misleading in two ways:
- The
TypeErroris thrown inside GSAP, not by HyperFrames, andgetBoundingClientRecthas nothing to do with the actual call site. - The advice to "check
window.__timelines" points at a downstream symptom — the script aborted before reaching thewindow.__timelines["main"] = tlassignment, so the entry is genuinely missing.
The cause is that the runtime's startup-error handler keeps only e.name / e.message and discards e.stack, so the real throw site never reaches the CLI.
Where
packages/.../hyperframe.runtime.iife.js (shipped at node_modules/hyperframes/dist/hyperframe.runtime.iife.js, and duplicated in dist/chunk-UYBXHV7R.js):
function _o(e){
let t="unknown error";
try{ t = e instanceof Error ? `${e.name}: ${e.message}` : String(e) } // <-- e.stack dropped here
catch(n){ W("runtime.startupError",n) }
window.__hfStartupError = `HyperFrames runtime failed: ${t}. Check the composition's scripts and window.__timelines entries; ...`
}Version tested: 0.8.198.
Reproduction
index.html (deterministic, no network beyond the GSAP CDN, 20s / 30fps / 1920x1080):
<!doctype html><html lang="en"><head><meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<style>*{margin:0;padding:0;box-sizing:border-box}html,body{width:1920px;height:1080px;overflow:hidden;background:#070c18}
.g{position:absolute;inset:0;display:grid;grid-template-columns:repeat(24,1fr);grid-template-rows:repeat(14,1fr);gap:3px}
.a,.b{background:#14213a}.b{opacity:0}</style></head><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="20" data-fps="30" data-width="1920" data-height="1080">
<div class="clip g" data-start="0" data-duration="20" data-track-index="0"></div>
<div class="clip g" data-start="0" data-duration="20" data-track-index="1"></div></div>
<script>
const gs=document.getElementsByClassName("g");
for(let i=0;i<336;i++)gs[0].appendChild(Object.assign(document.createElement("i"),{className:"a"}));
for(let i=0;i<336;i++)gs[1].appendChild(Object.assign(document.createElement("i"),{className:"b"}));
const tl=gsap.timeline({paused:true});
tl.to(".a",{opacity:0.25,duration:0.9,repeat:21,yoyo:true,stagger:{each:0.012,from:"start",grid:{rows:14,cols:24}}},0);
tl.to(".b",{opacity:1,duration:0.5,repeat:0,stagger:{each:0.006,from:"center",grid:{rows:14,cols:24}}},9.5);
tl.to(".b",{opacity:0,duration:0.5,repeat:0,stagger:{each:0.006,from:"center",grid:{rows:14,cols:24}}},12.2);
window.__timelines["main"]=tl;tl.seek(0);
</script></body></html>npx hyperframes render . -o out.mp4 --quality draft --fps 5
Fails in ~2 seconds with the message quoted above.
The underlying trigger is my own bug — stagger.grid was passed an object instead of [rows, cols]. Worth noting for anyone else hitting it: the misuse is not reliably fatal on its own. A single stagger with the object form renders fine; it only throws once two sequential tweens share a target set with the object form. That combination-dependence is exactly why the diagnostic quality matters here — the repro took four rounds of bisecting, and every round was pointed at the wrong component by the error text.
With the stack preserved, the same failure reads:
... (reading 'getBoundingClientRect') [thrown at: at https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js:11:6130 <- at new Tween (...)]
which names GSAP immediately.
Proposed fix
Append the stack frames to the message, single-line, because both the progress bar and the JSON log render error on one line and truncate at newlines. Keep the first few frames plus the frame that points back at the user's own file:
t = e instanceof Error
? `${e.name}: ${e.message}${e.stack ? ` [thrown at: ${(()=>{
let f = String(e.stack).split("\n").map(s => s.trim()).filter(Boolean).slice(1),
k = f.slice(0, 3),
c = f.find(s => /index\.html/.test(s) && !/gsap|hyperframe/i.test(s));
if (c && !k.includes(c)) k.push(c);
return k.join(" <- ");
})()}]` : ""}`
: String(e)I ran this locally as a post-install patch against the shipped dist/ and it behaves correctly. The script is below in case it is useful as a starting point for the real fix — it is idempotent and supports --check / --revert.
#!/usr/bin/env node
// 修掉 HyperFrames B2:运行时启动错误只取 e.message、丢掉 e.stack,
// 导致页内脚本崩溃时报成 "Cannot read properties of undefined (reading
// 'getBoundingClientRect')" 这种与真实原因无关的误导信息。
//
// 补丁给错误文案追加真实抛出位置(含 gsap 帧与 index.html 行号),
// 并重算 hyperframe.manifest.json 的 sha256 —— 运行时有完整性校验,
// 不改 manifest 会报 "Runtime checksum mismatch" 直接拒渲染。
//
// node tools/patch-startup-error.mjs 打补丁(幂等)
// node tools/patch-startup-error.mjs --revert 还原
//
// npm install 会覆盖 node_modules,之后重跑一次本脚本即可。
import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const DIST = resolve(HERE, "../node_modules/hyperframes/dist");
const IIFE = join(DIST, "hyperframe.runtime.iife.js");
const MANIFEST = join(DIST, "hyperframe.manifest.json");
const MARK = "[thrown at:";
// 运行时源码在打包时出现两份:注入页面的 iife,和 CLI 侧的 chunk。
const BUNDLES = [IIFE, join(DIST, "chunk-UYBXHV7R.js")];
const FROM =
'function _o(e){let t="unknown error";try{t=e instanceof Error?`${e.name}: ${e.message}`:String(e)}catch(n){W("runtime.startupError",n)}';
// 单行输出:CLI 进度条与 JSON 日志都按单行渲染,换行会被截断。
// 取前 3 帧,并强制带上 index.html 那一帧(那才是用户自己写错的行号)。
const TO =
'function _o(e){let t="unknown error";try{t=e instanceof Error?`${e.name}: ${e.message}${e.stack?` [thrown at: ${(()=>{let f=String(e.stack).split("\\n").map(s=>s.trim()).filter(Boolean).slice(1),k=f.slice(0,3),c=f.find(s=>/index\\.html/.test(s)&&!/gsap|hyperframe/i.test(s));if(c&&!k.includes(c))k.push(c);return k.join(" <- ")})()}]`:""}`:String(e)}catch(n){W("runtime.startupError",n)}';
const sha256 = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
const revert = process.argv.includes("--revert");
const bail = (msg) => {
console.error(msg);
process.exit(1);
};
if (!existsSync(IIFE)) {
bail(`找不到 ${IIFE}\n请先在 hf-test 目录执行 npm install。`);
}
if (process.argv.includes("--check")) {
const patched = readFileSync(IIFE, "utf8").includes(MARK);
const declared = existsSync(MANIFEST)
? (readFileSync(MANIFEST, "utf8").match(/"sha256"\s*:\s*"([0-9a-f]{64})"/) || [])[1]
: undefined;
const actual = sha256(IIFE);
console.log(`补丁状态:${patched ? "已生效" : "未打(npm install 后被冲掉了,重跑本脚本)"}`);
console.log(`校验一致:${declared === actual ? "通过" : `失败 manifest=${declared?.slice(0, 12)}… 实际=${actual.slice(0, 12)}…`}`);
process.exit(patched && declared === actual ? 0 : 1);
}
let touched = 0;
for (const file of BUNDLES) {
if (!existsSync(file)) {
console.log(`跳过(上游未再产出该文件):${file}`);
continue;
}
const src = readFileSync(file, "utf8");
if (!existsSync(`${file}.orig`)) {
copyFileSync(file, `${file}.orig`);
}
if (src.includes(MARK)) {
console.log(`已是补丁状态:${file}`);
} else if (!src.includes(FROM)) {
console.log(`⚠ 未匹配到目标代码,上游可能重构了错误处理,需人工核对:${file}`);
} else {
writeFileSync(file, src.replace(FROM, TO));
console.log(`已打补丁:${file}`);
touched++;
}
}
// 只有 iife 参与完整性校验,改完必须同步 manifest,否则渲染直接被拒。
if (existsSync(MANIFEST)) {
if (!existsSync(`${MANIFEST}.orig`)) copyFileSync(MANIFEST, `${MANIFEST}.orig`);
const manifest = readFileSync(MANIFEST, "utf8");
const next = sha256(IIFE);
const updated = manifest.replace(/"sha256"\s*:\s*"[0-9a-f]{64}"/, `"sha256": "${next}"`);
if (updated === manifest) {
console.log("⚠ manifest 里没找到 sha256 字段,校验可能失败,需人工核对。");
} else {
writeFileSync(MANIFEST, updated);
console.log(`已同步校验哈希:${next.slice(0, 12)}…`);
touched++;
}
}
console.log(
touched
? `\n补丁完成,本次改动 ${touched} 个文件。验证:node tools/patch-startup-error.mjs --check`
: "\n无需改动。"
);Two related observations
1. snapshot hides the failure. hyperframes snapshot --at 12.0 succeeds and writes a plausible PNG even when the composition script threw at line 1 — it does not gate on the timeline. Every frame captured after the crash is just the resting-state DOM, which looks like "the animation is working". If snapshot reported window.__timelines["main"] being absent (or absent-by-crash) it would have saved a lot of time. Consider making snapshot surface __hfStartupError when it is set.
2. Editing the runtime requires updating the checksum. dist/hyperframe.manifest.json carries sha256 for hyperframe.runtime.iife.js, and the loader rejects a mismatch with Runtime checksum mismatch before any frame is captured. That is the right guard, but it is not mentioned anywhere I looked, and it makes the obvious "patch the bundle to debug my composition" route fail with a second unrelated error. Worth a line in the docs.
Environment
- HyperFrames CLI 0.8.198
- Windows 11 Build 26200 x64, Node v24.18.0
- chrome-headless-shell 152.0.7977.30 (CLI-managed download)
- FFmpeg 9.0.2
Happy to retest a fix on this setup — the repro above is stable and fails in about 2 seconds.
Activity
miguel-heygen commentedon Oct 25, 2026
Thanks for the detailed write-up and for bisecting this down. I checked current main and the cause is as you describe:
recordStartupErrorinpackages/core/src/runtime/diagnostics.ts:36builds the message fromerror.nameanderror.messageonly, so the stack never reaches the CLI, and the advice aboutwindow.__timelinesthen points at a downstream symptom. I haven't run your fixture myself; this is from reading the source.A PR is welcome. A few pointers so it lands in one round:
recordStartupErrorinpackages/core/src/runtime/diagnostics.ts, with the test inpackages/core/src/runtime/diagnostics.test.ts: a thrownErrorwith a stack should produce a message that includes the top frames, on a single line as you describe.