fix(core): install runtime bridge after transport setup (#2558)

## Summary

The Studio preview now installs the runtime bridge only after its transport is ready, preventing initialization-order gaps without changing the bridge contract.

## Stack

Part 1 of 20. Parent: `main`. Next: heygen-com/hyperframes#2559. The golden reference, heygen-com/hyperframes#2387, remains open and unchanged.

## Test plan

- [x] Integrated Studio suite: 2,800 tests passed
- [x] Integrated parser suite: 853 tests passed
- [x] Studio and parser typechecks passed
- [x] Studio and parser production builds passed
- [ ] Per-PR CI completes on the submitted stack

## Post-Deploy Monitoring & Validation

Validation window: first 24 hours after the stack merges. Owner: Studio maintainers. Watch browser console and support reports for `[Timeline]`, `gsap-parser`, failed keyframe mutations, or preview/render easing mismatches. Healthy means edits persist and preview/render agree; revert the first failing layer if authored animation data changes unexpectedly.

---

[![Compound Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
![Codex](https://img.shields.io/badge/GPT--5-000000)
This commit is contained in:
Miguel Ángel
2026-07-19 21:49:02 -04:00
committed by GitHub
parent 458df4c412
commit 735128a61a
2 changed files with 175 additions and 117 deletions
+53
View File
@@ -1845,6 +1845,59 @@ describe("initSandboxRuntimeModular", () => {
expect(seekTimes[seekTimes.length - 1]).toBe(0);
});
it("accepts replayed transport controls when the bridge announces ready without duplicate listeners", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "5");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const timeline = createMockTimeline(5);
timeline.timeScale = vi.fn();
window.__timelines = { root: timeline };
const outbound: Array<Record<string, unknown>> = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
if (typeof message !== "object" || message === null) return;
const payload = message as Record<string, unknown>;
outbound.push(payload);
if (payload.source !== "hf-preview" || payload.type !== "ready") return;
window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "seek",
timeSeconds: 2,
},
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "set-playback-rate",
playbackRate: 2,
},
}),
);
});
expect(() => initSandboxRuntimeModular()).not.toThrow();
expect(() => initSandboxRuntimeModular()).not.toThrow();
expect(timeline.time()).toBe(2);
expect(timeline.timeScale).toHaveBeenLastCalledWith(2);
expect(outbound.filter((message) => message.type === "ready")).toHaveLength(2);
expect(
outbound.filter(
(message) => message.type === "analytics" && message.event === "composition_seeked",
),
).toHaveLength(2);
});
it("restores timed element visibility after a forced timeline rebind", () => {
document.body.innerHTML = `
<div data-composition-id="root" data-root="true" data-duration="30" data-width="1920" data-height="1080">
+122 -117
View File
@@ -2373,118 +2373,6 @@ export function initSandboxRuntimeModular(): void {
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null,
});
state.controlBridgeHandler = installRuntimeControlBridge({
onPlay: () => {
player.play();
emitAnalyticsEvent("composition_played", { time: player.getTime() });
},
onPause: () => {
player.pause();
emitAnalyticsEvent("composition_paused", { time: player.getTime() });
},
onStopMedia: () => {
webAudio.stopAll();
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (el instanceof HTMLMediaElement && !el.paused) el.pause();
}
},
onSeek: (timeSeconds, _seekMode) => {
player.seek(timeSeconds);
emitAnalyticsEvent("composition_seeked", { time: timeSeconds });
},
onSetMuted: (muted) => {
state.bridgeMuted = muted;
const effective = muted || state.mediaOutputMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective || el.defaultMuted;
}
},
onSetVolume: (volume) => {
state.bridgeVolume = volume;
webAudio.setVolume(volume);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
const parsed = parseFloat(el.dataset.volume ?? "");
const clipVolume = Number.isFinite(parsed) ? parsed : 1;
el.volume = clipVolume * volume;
}
},
onSetMediaOutputMuted: (muted) => {
state.mediaOutputMuted = muted;
const effective = muted || state.bridgeMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective || el.defaultMuted;
}
},
onSetNativeMediaSyncDisabled: (disabled) => {
if (state.nativeMediaSyncDisabled === disabled) return;
state.nativeMediaSyncDisabled = disabled;
state.mediaForceSyncNextTick = true;
if (disabled) {
webAudio.stopAll();
clock.detachAudioSource();
} else {
syncMediaForCurrentState();
}
},
onSetWebAudioMediaDisabled: (disabled) => {
if (state.webAudioMediaDisabled === disabled) return;
state.webAudioMediaDisabled = disabled;
state.mediaForceSyncNextTick = true;
if (disabled) {
webAudio.stopAll();
clock.detachAudioSource();
syncMediaForCurrentState();
} else {
syncMediaForCurrentState();
}
},
onSetPlaybackRate: (rate) => {
applyPlaybackRate(rate);
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
applyWebAudioRate();
},
onSetRootDuration: growRootDurationLive,
onSetColorGrading: (target, grading) => {
colorGrading.setGrading(target, grading);
},
onSetColorGradingCompare: (target, compare) => {
colorGrading.setCompare(target, compare);
},
onTick: () => {
if (state.tornDown || !clock.isPlaying()) return;
const t = clock.now();
state.currentTime = t;
seekTimelineAndAdapters(t);
if (clock.reachedEnd()) {
webAudio.stopAll();
clock.detachAudioSource();
clock.pause();
state.isPlaying = false;
const dur = clock.getDuration();
if (Number.isFinite(dur)) {
clock.seek(dur);
state.currentTime = dur;
seekTimelineAndAdapters(dur);
}
runAdapters("pause");
syncMediaForCurrentState();
postState(true);
}
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
getCanonicalFps: () => state.canonicalFps,
});
state.deterministicAdapters = [
createWaapiAdapter(),
createCssAdapter({
@@ -2756,10 +2644,10 @@ export function initSandboxRuntimeModular(): void {
return false;
};
const seekTimelineAndAdapters = (
function seekTimelineAndAdapters(
t: number,
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
) => {
) {
const tl = state.capturedTimeline;
const suppressEvents = opts?.suppressEvents === true;
if (tl) {
@@ -2825,7 +2713,7 @@ export function initSandboxRuntimeModular(): void {
swallow("runtime.init.transport.adapter", err);
}
}
};
}
// True while the Studio is mid-drag on an element (the gesture marker is
// stamped on the gestured element for the duration of the drag). During a
@@ -3061,7 +2949,7 @@ export function initSandboxRuntimeModular(): void {
// rescaled in place; but a bounded source's window was baked into start()'s
// duration at its prior rate and can't be rescaled, so when one is active we
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
const applyWebAudioRate = () => {
function applyWebAudioRate() {
const changed = webAudio.setRate(state.playbackRate);
if (
changed &&
@@ -3074,7 +2962,7 @@ export function initSandboxRuntimeModular(): void {
webAudio.stopAll();
scheduleWebAudioForActiveClips();
}
};
}
// Sync clock duration from any captured timeline
if (state.capturedTimeline) {
@@ -3090,6 +2978,123 @@ export function initSandboxRuntimeModular(): void {
postTimeline();
postState(true);
// Wire the control bridge LAST — after every transport helper its handlers
// dispatch to (seekTimelineAndAdapters, applyWebAudioRate, ...) is declared.
// The runtime's external control surface only goes live once all of its
// dependencies exist, so a load-time seek / set-playback-rate can never reach
// a not-yet-initialized helper (the 'before initialization' TDZ this fixes).
state.controlBridgeHandler = installRuntimeControlBridge({
onPlay: () => {
player.play();
emitAnalyticsEvent("composition_played", { time: player.getTime() });
},
onPause: () => {
player.pause();
emitAnalyticsEvent("composition_paused", { time: player.getTime() });
},
onStopMedia: () => {
webAudio.stopAll();
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (el instanceof HTMLMediaElement && !el.paused) el.pause();
}
},
onSeek: (timeSeconds, _seekMode) => {
player.seek(timeSeconds);
emitAnalyticsEvent("composition_seeked", { time: timeSeconds });
},
onSetMuted: (muted) => {
state.bridgeMuted = muted;
const effective = muted || state.mediaOutputMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective || el.defaultMuted;
}
},
onSetVolume: (volume) => {
state.bridgeVolume = volume;
webAudio.setVolume(volume);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
const parsed = parseFloat(el.dataset.volume ?? "");
const clipVolume = Number.isFinite(parsed) ? parsed : 1;
el.volume = clipVolume * volume;
}
},
onSetMediaOutputMuted: (muted) => {
state.mediaOutputMuted = muted;
const effective = muted || state.bridgeMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective || el.defaultMuted;
}
},
onSetNativeMediaSyncDisabled: (disabled) => {
if (state.nativeMediaSyncDisabled === disabled) return;
state.nativeMediaSyncDisabled = disabled;
state.mediaForceSyncNextTick = true;
if (disabled) {
webAudio.stopAll();
clock.detachAudioSource();
} else {
syncMediaForCurrentState();
}
},
onSetWebAudioMediaDisabled: (disabled) => {
if (state.webAudioMediaDisabled === disabled) return;
state.webAudioMediaDisabled = disabled;
state.mediaForceSyncNextTick = true;
if (disabled) {
webAudio.stopAll();
clock.detachAudioSource();
syncMediaForCurrentState();
} else {
syncMediaForCurrentState();
}
},
onSetPlaybackRate: (rate) => {
applyPlaybackRate(rate);
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
applyWebAudioRate();
},
onSetRootDuration: growRootDurationLive,
onSetColorGrading: (target, grading) => {
colorGrading.setGrading(target, grading);
},
onSetColorGradingCompare: (target, compare) => {
colorGrading.setCompare(target, compare);
},
onTick: () => {
if (state.tornDown || !clock.isPlaying()) return;
const t = clock.now();
state.currentTime = t;
seekTimelineAndAdapters(t);
if (clock.reachedEnd()) {
webAudio.stopAll();
clock.detachAudioSource();
clock.pause();
state.isPlaying = false;
const dur = clock.getDuration();
if (Number.isFinite(dur)) {
clock.seek(dur);
state.currentTime = dur;
seekTimelineAndAdapters(dur);
}
runAdapters("pause");
syncMediaForCurrentState();
postState(true);
}
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
getCanonicalFps: () => state.canonicalFps,
});
const teardown = () => {
if (state.tornDown) return;
state.tornDown = true;