Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,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">
Expand Down
239 changes: 122 additions & 117 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2316,118 +2316,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({
Expand Down Expand Up @@ -2701,10 +2589,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) {
Expand Down Expand Up @@ -2772,7 +2660,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
Expand Down Expand Up @@ -3008,7 +2896,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 &&
Expand All @@ -3021,7 +2909,7 @@ export function initSandboxRuntimeModular(): void {
webAudio.stopAll();
scheduleWebAudioForActiveClips();
}
};
}

// Sync clock duration from any captured timeline
if (state.capturedTimeline) {
Expand All @@ -3037,6 +2925,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;
Expand Down
Loading