fix(runtime): decide a nested clip's timing convention by its start, not its end - #3326
fix(runtime): decide a nested clip's timing convention by its start, not its end#3326felipecaldas wants to merge 1 commit into
Conversation
miga-heygen
left a comment
There was a problem hiding this comment.
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. ThehostEnd != nullbranch adds&& authoredStart < hostEndas 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.4→authoredStart >= inheritedStart→ true → root-global. The>=keeps this case on the same path. The existingpip-video-late-hosttest 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@1→1 >= 5→ false →5 + 1 = 6. Correct — the clip starts 1s into the host. - Root scene early return:
inheritedStart <= 0returnsauthoredStartdirectly, 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:
authoredDurationandauthoredEndfully removed. No surviving references in comments, tests, or logic. - Blast radius: Only clips with
0 <= authoredStart < hostStarttake 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
|
@felipecaldas can you sign the commit to merge it pls? |
|
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.
e1e062c to
6461ad2
Compare
|
@miguel-heygen it's very verified :) |
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>
Summary
resolveAbsoluteMediaStartSecondspicks 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
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:
Note the two branches above already disagree with each other — the
hostEnd == nullbranch 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.maininheritedStart <= 0→ early returnEvery 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:It builds a host at
data-start="2.96" data-duration="4.375"containing a video atdata-start="0.000" data-duration="4.375", then asserts__hfResolveMediaStartSecondsresolves to2.96and that the clip is still visible att=7.2(late in its own slot).On
mainit fails: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?":
On the project above (backdrop
#0B0B0F≈ 15 = nothing painted, content ≈ 90–120):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.583→8.000, slot untouched at 7.333–9.917: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-start0.000→2.960produced exactly the relative-semantics result), occlusion by a backdrop, and z-order viadata-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-host—data-start="3.0"inside a host at3.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.That last point cuts both ways: the class I changed is untested here either way, so the
>=choice for0 < authoredStart < hostStartis 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 passnested-sequential-video-local-startandpip-video-late-host— 2/2 passed, 0 failed frames