Description
Context: I've been using HyperFrames every day for the past two weeks to make Korean Shorts and long-form videos, and it's been great to work with. This is one of a batch of things I ran into along the way (most came as small PRs from me today) — sorry for the pile-up, and thanks for your patience.
Describe the bug
On projects built from data-composition-src scenes plus <audio>/<video> clips, hyperframes check --no-cache gives different findings on every run of an unchanged project, and some of them describe the t=0 frame instead of the sampled time. Two independent projects showed it from different sides:
Counts drift. A 37 s Short (nine scenes, a voice-over <audio> per scene), six back-to-back runs of 0.9.43:
| run | layout (info) | contrast (warning) | craft findings |
|---|---|---|---|
| 1 | 6 | 1 | 10 |
| 2 | 9 | 0 | 10 |
| 3 | 9 | 1 | 11 |
| 4 | 5 | 1 | 10 |
| 5 | 5 | 1 | 10 |
| 6 | 8 | 0 | 10 |
The flapping container_overflows are each scene's Ken Burns wrapper, present when the sample sees it mid-zoom and absent when it sees scale 1. In another project a line drops out of one 8 fps craft sample, so line_unread measures 1.21 s instead of 2.10 s.
False contrast against the t=0 frame. Dark text on a light paper layer revealed by a 0.55 s clipPath tween was reported as
contrast_aa_failure #t2a Contrast is 1.05:1; WCAG AA requires 3:1. fg rgb(21,20,16) bg rgb(13,13,14)
rgb(13,13,14) is the root's dark ground, which is what that spot shows at t=0, before the paper is revealed; a snapshot at the same time shows the text on paper. The background logic is fine (solidBackdrop returns null because the paper paints the region); the screenshot is of the wrong time. 7 such findings appeared in 5 of 15 runs, at different sample times. The number of text checks also varies (checked 14 / 20 / 23 / 24 / 39), because samples whose text is at opacity 0 at t=0 are silently skipped.
Minimal repro (main affa03b6, CLI 0.9.43)
index.html: rootdata-composition-id="main"with an emptywindow.__timelines.main = gsap.timeline({ paused: true }), one host<div data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="12">, and six<audio class="clip">SFX (0.6 s sine WAVs) on track 6.compositions/scene.html: dark#root, a full-frame light#paperrevealed bytl.fromTo("#paper", { clipPath: "inset(100% 0% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.55 }, 0), and two dark text lines fading in at 0.6 s and 3 s.
CI=1 hyperframes check --no-cache --json (CI=1 so no warm check process is reused):
| variant | runs with false #t1/#t2 1.05:1 (bg rgb(13,13,14)) |
|---|---|
with the six <audio> |
4 of 6 |
same project, <audio> removed |
0 of 5 |
Cause
(Line numbers at affa03b6.) Instrumenting the check page — wrapping the scene timelines' methods and dumping every window.__timelines[*].time() around each sample — shows the page clock (__player.getTime()) stays right while every scene timeline is put back at 0 between renderSeek and the read:
renderSeek [35.357]
s01.render [2.758] ← seekSceneTimeline puts the scene at its local time
s01.totalTime [0, 2.758] at Timeline.paused ← ensureChildCandidatesActive ← resolveRootTimelineFromDocument ← metadata debounce timer
s01.render [0] ← the scene is back at 0 s; check reads the frame now
- Every
<audio>/<video>registersscheduleMetadataDurationHydration(packages/core/src/runtime/init.ts:2648) onloadedmetadataanddurationchange(:2803-2804). AfterMETADATA_REBIND_DEBOUNCE_MS(100 ms,:875) it callsresolveRootTimelineFromDocument()(:2672) before deciding whether to rebind. On a check page those events keep arriving during the sweep, as clips near the seek position load. resolveRootTimelineFromDocument()is not read-only:ensureChildCandidatesActive(:1919,paused(false)at:1928) nests and un-pauses the scene timelines, so GSAP renders each at its parent's playhead. UnderrenderSeekthe scenes are driven standalone (seekStandaloneRegisteredTimelines,:5714) while the root stays at 0, so the visible frame jumps to t=0 — even when the timer then returns without rebinding.- The timer is skipped only when
isExportRenderDrivingFrames()(:1428:renderCaptureSeekStarted && window.__HF_EXPORT_RENDER_SEEK_CONFIG != null); its comment names exactly this hazard for renders (HF#2550).checkdrives frames with the samerenderSeekbut sets no__HF_EXPORT_RENDER_SEEK_CONFIG, so it isn't covered. (init.test.ts:4196keeps the rebind on afterrenderSeekwithout that config on purpose, for Studio's static-seek preview.) - The craft sweep uses
DENSE_GEOMETRY_SEEK_OPTIONS(no settle) and reads straight after the seek, so whether the timer lands in between depends on when audio happens to load.
Verification
With a debug-only change making the timer return early once renderCaptureSeekStarted is set (not proposed as the fix), the 37 s Short gave identical results in 6/6 runs with one Chrome and 4/4 with the helper Chrome (layout 9 info, contrast 0, craft 10), and the second project gave 4/4 identical craft results.
Expected behavior
A seek and the read right after it describe the same time, so the same project and options give the same findings on every run.
Possible directions
- Let
checkmark its page as frame-driven, like the producer, so everything gated onisExportRenderDrivingFramestreats it as a render — with a marker onlycheckand render set, since Studio's static-seek adapter must keep the self-correction. - Split the probing part of
resolveRootTimelineFromDocumentfrom the part that nests and un-pauses scenes, so a rebind that isn't applied changes nothing; re-seek tostate.currentTimeafter one that is. - When the timer runs on a
renderSeek-driven page, put back the framerenderSeekset before returning (e.g.seekTimelineAndAdapters(state.currentTime)in the same task).
Happy to send a PR for whichever direction you prefer.
Environment
hyperframes 0.9.43 (affa03b6), macOS arm64, chrome-headless-shell 152, Node 22.16.
Activity