Skip to content

fix(runtime): decide a nested clip's timing convention by its start, not its end - #3326

Open
felipecaldas wants to merge 1 commit into
heygen-com:mainfrom
felipecaldas:fix/nested-clip-timing-convention
Open

fix(runtime): decide a nested clip's timing convention by its start, not its end#3326
felipecaldas wants to merge 1 commit into
heygen-com:mainfrom
felipecaldas:fix/nested-clip-timing-convention

Conversation

@felipecaldas

Copy link
Copy Markdown

Summary

resolveAbsoluteMediaStartSeconds picks between the two conventions #2859 identified by testing the clip's authored end. What actually distinguishes them is where the clip starts, so a composition-local clip is misread as root-global whenever its duration merely exceeds its host's mount offset — and it renders blank for the tail of its own slot.

This is the same failure #2859 fixed, one case further along.

The bug

const authoredEnd = authoredDuration > 0 ? authoredStart + authoredDuration : authoredStart;
const overlapsHostWindow =
  hostEnd == null
    ? authoredStart >= inheritedStart                    // start-based
    : authoredStart < hostEnd &&
      (authoredEnd > inheritedStart || authoredStart === inheritedStart);   // end-based
return overlapsHostWindow ? authoredStart : inheritedStart + authoredStart;

A composition-local clip is authored from its host's zero, so what identifies it is that its start sits below the host's absolute start. Its duration says nothing about which convention it uses.

Worked example, from a real project:

host slot   data-start="2.96"  data-duration="4.375"     (2.96 .. 7.335)
inner video data-start="0.000" data-duration="4.375"     (composition-local)

authoredEnd = 0 + 4.375 = 4.375  >  inheritedStart 2.96   ->  read as ROOT-GLOBAL
-> scheduled 0 .. 4.375
-> the ancestor visibility gate clips the front to the host window
-> visible 2.96 .. 4.375, blank for the remaining ~3s of its own slot

Note the two branches above already disagree with each other — the hostEnd == null branch tests the start. This PR makes both use the start, which is also what the comment above them describes.

Why it hides

The misreading only produces a visible hole when 0 < mountOffset < duration.

scene mount duration verdict on main outcome
0 0 2.958 inheritedStart <= 0 → early return fine
1 2.96 4.375 4.375 > 2.96 → "root-global" ~3s blank
2 7.333 2.583 2.583 < 7.333 → local fine
3–5 later shorter local fine

Every other scene in that project mounts later than its own duration, so it fell through to the correct branch by accident. Five scenes correct by luck and one broken looks exactly like a healthy project.

How to reproduce

1. Deterministic, in this repo — the added test. packages/core/src/runtime/init.test.ts:

keeps a composition-local clip visible for its whole slot when its duration exceeds the mount offset

It builds a host at data-start="2.96" data-duration="4.375" containing a video at data-start="0.000" data-duration="4.375", then asserts __hfResolveMediaStartSeconds resolves to 2.96 and that the clip is still visible at t=7.2 (late in its own slot).

On main it fails:

bunx vitest run --config packages/core/vitest.config.ts \
  packages/core/src/runtime/init.test.ts

It asserts a late instant inside the slot, not just the resolved start — the resolved start alone was never the visible symptom.

2. In a render. Mean frame luminance is the cheapest way to ask "did anything paint?":

hyperframes render <project> --output /tmp/r.mp4
ffmpeg -ss 5.5 -i /tmp/r.mp4 -frames:v 1 -vf format=gray -f rawvideo - \
  | python3 -c "import sys;d=sys.stdin.buffer.read();print(sum(d)/len(d))"

On the project above (backdrop #0B0B0F ≈ 15 = nothing painted, content ≈ 90–120):

t (s) 0.5 2.5 3.5 4.5 5.5 6.5 7.5 9.5 12.5 15.5
before 107 107 104 15 16 16 161 131 107 128
after 107 107 104 89 73 75 161 131 107 128

After the change, a 76-frame scan at 0.25s across the full 18.9s render finds no unpainted frame.

Honest caveat on repro: I could not get this to reproduce in a stripped-down standalone project — a two-file project with the same attribute values, and even one built by lifting the failing slot and composition verbatim out of the real project, both rendered correctly on unpatched code. So something about the surrounding project context is part of the trigger and I have not isolated it. The unit test above is deterministic and does fail on main, and the real-project before/after is measured, but if you want a minimal standalone fixture, that gap is real and I'd rather flag it than hand you something I hadn't verified.

The evidence that identified the mechanism

Rather than fit a theory to the failure, I made it predict something counterintuitive: if a clip's duration is what tips it into being misread, then making a healthy clip longer should create a new hole.

scene-2 above is healthy. Its duration 2.5838.000, slot untouched at 7.333–9.917:

t=7.5   base 161.4    ->  161.4
t=7.9   base 160.2    ->  160.1
t=8.1   base 160.6    ->   15.4   <- new hole, at exactly 8.0
t=9.5   base 131.2    ->   15.5

A longer clip rendering less, with the cutoff landing exactly on the new duration.

Three other hypotheses were eliminated first, each by a single-variable render: absolute-vs-relative composition time (changing the inner data-start 0.0002.960 produced exactly the relative-semantics result), occlusion by a backdrop, and z-order via data-track-index (moving the affected slot to track 0 and a healthy one to track 5 gave byte-identical renders).

Blast radius

Behaviour changes only for clips with 0 <= authoredStart < hostStart. Everything else takes the same branch as before.

  • pip-video-late-hostdata-start="3.0" inside a host at 3.0 — is unaffected, since >= still reads it as root-global. Its unit test and golden render both pass.
  • nested-sequential-video-local-start (from fix: offset nested template video timing #2859) passes.
  • I could find no fixture in this repo in the changed class.

That last point cuts both ways: the class I changed is untested here either way, so the >= choice for 0 < authoredStart < hostStart is a judgement call rather than something your fixtures pin. If you know of a shipped project shape where a root-global clip legitimately starts before its host, that case would want a different rule and I'd defer to you on it.

Verification

  • packages/core: 117 files / 2337 tests pass
  • regression harness: nested-sequential-video-local-start and pip-video-late-host — 2/2 passed, 0 failed frames
  • lint, format, typecheck clean
  • Negative control: restoring the end-based test fails exactly the new test, and leaves the legacy PIP test passing

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — clean, well-reasoned fix. The investigation in the PR description is exceptional.

The core change is a one-concept simplification: replace the end-based convention test (authoredEnd > inheritedStart) with a start-based one (authoredStart >= inheritedStart). This aligns both branches of the conditional (the hostEnd == null branch was already start-based) and makes the variable name accurate (authoredStartIsRootGlobal instead of overlapsHostWindow, which described intersection when the actual question is timing convention).

What the fix does

Before: a composition-local clip (data-start="0") inside a host at 2.96s with duration 4.375s computed authoredEnd = 0 + 4.375 = 4.375 > inheritedStart 2.96, so it read as root-global and scheduled itself at 0..4.375 — the ancestor gate clipped the front, leaving a 3s black hole.

After: the test is authoredStart >= inheritedStart, which is 0 >= 2.96 → false → composition-local, so the clip inherits the host offset and schedules at 2.96..7.335.

The dead authoredDuration / authoredEnd computation is fully removed — no stale references.

What I verified

  • SSOT: "Is this clip root-global?" now has one criterion across both branches: authoredStart >= inheritedStart. The hostEnd != null branch adds && authoredStart < hostEnd as a bounds guard (a root-global clip starting past its host's end is nonsensical, so composition-local is the safe fallback), but the primary discriminator is the same.
  • Legacy PIP case preserved: host@45.4 + video@45.4authoredStart >= inheritedStart → true → root-global. The >= keeps this case on the same path. The existing pip-video-late-host test confirms.
  • Boundary at authoredStart === inheritedStart: reads as root-global in both branches, which is correct for PIP-style clips whose start matches the host's.
  • Composition-local with sub-offset: host@5 + clip@11 >= 5 → false → 5 + 1 = 6. Correct — the clip starts 1s into the host.
  • Root scene early return: inheritedStart <= 0 returns authoredStart directly, so root-level clips never reach the changed code.
  • Test quality: Asserts both the resolved start (.toBeCloseTo(2.96)) AND visibility at the previously-black instants (5.5, 7.2). Also checks slot boundaries — hidden at 7.4 (past end) and 2.5 (before start). The "late instant" assertion is the critical one, catching the actual visible symptom rather than just the resolved value.
  • Stale concept scan: authoredDuration and authoredEnd fully removed. No surviving references in comments, tests, or logic.
  • Blast radius: Only clips with 0 <= authoredStart < hostStart take a different branch. Everything else is unchanged.

The honest caveat about not isolating the full trigger in a standalone project is appreciated — the unit test is deterministic and the real-project measurements are convincing, and hand-waving away the gap would have been worse than flagging it.

Review by Miga

@miguel-heygen

Copy link
Copy Markdown
Collaborator

@felipecaldas can you sign the commit to merge it pls?

@felipecaldas

Copy link
Copy Markdown
Author

absolutely, ill get it done when tomorrow as its past midnight here 👍

…not its end

`resolveAbsoluteMediaStartSeconds` disambiguates the two conventions heygen-com#2859
identified:

  composition-local   host@20   + video@0    => root@20
  legacy root-global  host@45.4 + video@45.4 => root@45.4

It decides by asking whether the clip's authored *end* lands inside the host
window. What distinguishes the two is where the clip *starts*: a
composition-local clip is authored from its host's zero, so its start sits below
the host's absolute start. Its duration says nothing about which convention it
uses.

So a composition-local clip is misread as root-global whenever its duration
merely exceeds the mount offset:

  data-start="0", data-duration="4.375", host mounted at 2.96
  authoredEnd = 0 + 4.375 = 4.375 > 2.96  ->  treated as root-global
  -> scheduled 0..4.375; the ancestor visibility gate clips the front
  -> visible 2.96..4.375, blank for the remaining ~3s of its own slot

The two branches of that test already disagreed — the no-host-duration branch
tested `authoredStart >= inheritedStart`, the other tested the end. This makes
both use the start.

This is the same failure heygen-com#2859 fixed, one case further along: that PR handled
the clip whose end falls *before* the mount offset (which falls through to
local). A clip whose end falls *after* it flips back to global instead.

The condition only produces a visible hole when 0 < mountOffset < duration, so a
project can look entirely healthy while carrying it — every other scene in the
project where we hit this mounts later than its own duration and resolved
correctly by luck.

Behaviour changes only for clips with 0 <= authoredStart < hostStart. The
existing pip-video-late-host fixture (data-start 3.0 inside a host at 3.0) is
unaffected by the `>=`, and its golden render still passes, as does
nested-sequential-video-local-start.
@felipecaldas
felipecaldas force-pushed the fix/nested-clip-timing-convention branch from e1e062c to 6461ad2 Compare August 18, 2026 23:30
@felipecaldas

Copy link
Copy Markdown
Author

@miguel-heygen it's very verified :)

felipecaldas added a commit to felipecaldas/hyperframes that referenced this pull request Aug 19, 2026
Base moves v0.8.1 -> v0.8.3, taking two upstream releases. Merged the tag, not
moving `main`, so the branch stays pinned to a release (TAB-782).

Price, measured before starting: our patch surface is 87 files, upstream churned
297 to v0.8.3, and the two sets overlapped in 4. Three of those merged
themselves. One conflict, in one file.

That conflict is the TAB-792 nested-clip timing patch in
packages/core/src/runtime/init.ts, and it is worth recording what it actually
was. Upstream's only change to that function since our merge base was hardening
the parse of the authored duration — parseNumeric -> parseStrictFiniteTimingNumber
(heygen-com#3322). Our patch deletes that variable outright, because deciding the timing
convention by where a clip *starts* never reads its duration. So upstream edited
a line we had removed. Resolved by keeping ours; nothing of their fix is lost,
and the reasoning is now in the comment rather than in this message alone.

Upstream has not taken PR heygen-com#3326, so v0.8.3 still ships the end-based test and
this patch stays ours. Patch surface does not shrink.

Checked for the TAB-783 failure — an upstream refactor giving a patched concept a
second caller that no conflict marker would reveal. heygen-com#3322 is a 24-file change
that adds packages/engine/src/services/mediaTimelineWindow.ts, which was the
candidate. It is not one: it consumes an already-resolved start and only asks
whether a window is explicitly inactive, and its callers in audioMixer and
videoFrameExtractor resolve starts by their own path, as they did before.

Rebuilt before trusting any result, then: core 2397/2397 across 118 files —
including the 24 lines of tests heygen-com#3322 added to init.test.ts, run against our
start-based logic — studio-server 504/504, engine 1599 (3 skipped), cli 2686
(3 skipped).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants