Skip to content

Composition script crashes surface as an unrelated TypeError pointing at window.__timelines (runtime drops Error.stack) #6334

Closed

Description

@Jeneral0726

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:

  1. The TypeError is thrown inside GSAP, not by HyperFrames, and getBoundingClientRect has nothing to do with the actual call site.
  2. The advice to "check window.__timelines" points at a downstream symptom — the script aborted before reaching the window.__timelines["main"] = tl assignment, 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

  1. miguel-heygen commented on Oct 25, 2026

    @miguel-heygen
    Collaborator

    Thanks for the detailed write-up and for bisecting this down. I checked current main and the cause is as you describe: recordStartupError in packages/core/src/runtime/diagnostics.ts:36 builds the message from error.name and error.message only, so the stack never reaches the CLI, and the advice about window.__timelines then 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:

    • Change recordStartupError in packages/core/src/runtime/diagnostics.ts, with the test in packages/core/src/runtime/diagnostics.test.ts: a thrown Error with a stack should produce a message that includes the top frames, on a single line as you describe.
    • Please keep it short: a few frames, plus the first frame in the composition's own file. The comments should say what the code can't, in one to three lines.
    • The test should fail without the change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions