mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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);
|
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", () => {
|
it("restores timed element visibility after a forced timeline rebind", () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div data-composition-id="root" data-root="true" data-duration="30" data-width="1920" data-height="1080">
|
<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,
|
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 = [
|
state.deterministicAdapters = [
|
||||||
createWaapiAdapter(),
|
createWaapiAdapter(),
|
||||||
createCssAdapter({
|
createCssAdapter({
|
||||||
@@ -2756,10 +2644,10 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const seekTimelineAndAdapters = (
|
function seekTimelineAndAdapters(
|
||||||
t: number,
|
t: number,
|
||||||
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
|
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
|
||||||
) => {
|
) {
|
||||||
const tl = state.capturedTimeline;
|
const tl = state.capturedTimeline;
|
||||||
const suppressEvents = opts?.suppressEvents === true;
|
const suppressEvents = opts?.suppressEvents === true;
|
||||||
if (tl) {
|
if (tl) {
|
||||||
@@ -2825,7 +2713,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
swallow("runtime.init.transport.adapter", err);
|
swallow("runtime.init.transport.adapter", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// True while the Studio is mid-drag on an element (the gesture marker is
|
// 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
|
// 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
|
// 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
|
// 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.
|
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
|
||||||
const applyWebAudioRate = () => {
|
function applyWebAudioRate() {
|
||||||
const changed = webAudio.setRate(state.playbackRate);
|
const changed = webAudio.setRate(state.playbackRate);
|
||||||
if (
|
if (
|
||||||
changed &&
|
changed &&
|
||||||
@@ -3074,7 +2962,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
webAudio.stopAll();
|
webAudio.stopAll();
|
||||||
scheduleWebAudioForActiveClips();
|
scheduleWebAudioForActiveClips();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Sync clock duration from any captured timeline
|
// Sync clock duration from any captured timeline
|
||||||
if (state.capturedTimeline) {
|
if (state.capturedTimeline) {
|
||||||
@@ -3090,6 +2978,123 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
postTimeline();
|
postTimeline();
|
||||||
postState(true);
|
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 = () => {
|
const teardown = () => {
|
||||||
if (state.tornDown) return;
|
if (state.tornDown) return;
|
||||||
state.tornDown = true;
|
state.tornDown = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user