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. --- [](https://github.com/EveryInc/compound-engineering-plugin) 
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user