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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ packages/producer/tests/**/*.mp4 filter=lfs diff=lfs merge=lfs -text
packages/producer/tests/**/*.mov filter=lfs diff=lfs merge=lfs -text
packages/producer/tests/**/*.webm filter=lfs diff=lfs merge=lfs -text
packages/producer/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
packages/producer/tests/**/output/compiled.html filter=lfs diff=lfs merge=lfs -text

# ONNX models must ALWAYS use LFS regardless of location: a 31 MB ppmattingv2
# model was once committed raw into skills/ then deleted — but a raw commit
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/runtime/adapters/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
if (!timeline) return;
timeline.pause();
const safeTime = Math.max(0, Number(ctx.time) || 0);
const suppressEvents = ctx.suppressEvents === true;
if (typeof timeline.totalTime === "function") {
// GSAP 3.x skips rendering when the new totalTime equals _tTime.
// Nudge first to force a dirty state, then seek to the exact time.
timeline.totalTime(safeTime + 0.001, true);
timeline.totalTime(safeTime, false);
timeline.totalTime(safeTime, suppressEvents);
} else {
timeline.seek(safeTime, false);
timeline.seek(safeTime, suppressEvents);
}
},
pause: () => {
Expand Down
72 changes: 72 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,78 @@ describe("initSandboxRuntimeModular", () => {
expect(hookHost.style.visibility).toBe("visible");
});

it("keeps the root GSAP render nudge for normal frames but not silent probes", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
const rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};

window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular();
seekCalls.length = 0;

window.__player?.renderSeek(2);

expect(seekCalls).toEqual([
{ time: 2, suppressEvents: false },
{ time: 2.001, suppressEvents: true },
{ time: 2, suppressEvents: true },
]);

seekCalls.length = 0;
window.__player?.renderSeek(3, { suppressEvents: true });

expect(seekCalls).toEqual([{ time: 3, suppressEvents: true }]);
});

it("does not nudge root GSAP timelines that contain zero-duration callbacks", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
const rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};
Object.assign(rootTimeline, {
getChildren: () => [
{
vars: { onComplete: () => {} },
duration: () => 0,
totalDuration: () => 0,
},
],
});

window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular();
seekCalls.length = 0;

window.__player?.renderSeek(2);

expect(seekCalls).toEqual([{ time: 2, suppressEvents: false }]);
});

it("shows pip video at global start time even when host composition starts late", () => {
// Regression: resolveStartForElement used to add the host composition's start on top of
// the video's own data-start, causing double-offset. A pip video with data-start="45.40"
Expand Down
122 changes: 106 additions & 16 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../studio-api/helpers/draftMarkers";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type {
RuntimeDeterministicAdapter,
RuntimeJson,
RuntimeSeekOptions,
RuntimeTimelineLike,
} from "./types";
import type { PlayerAPI } from "../core.types";
import { swallow } from "./diagnostics";

Expand Down Expand Up @@ -205,7 +210,7 @@ export function initSandboxRuntimeModular(): void {
getTime: () => number;
getDuration: () => number;
isPlaying: () => boolean;
renderSeek: (timeSeconds: number) => void;
renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
}): PlayerAPI => {
const defaultStageZoom: ReturnType<PlayerAPI["getStageZoom"]> = {
scale: 1,
Expand Down Expand Up @@ -1901,7 +1906,7 @@ export function initSandboxRuntimeModular(): void {
}
if (method === "discover") {
try {
adapter.seek({ time: timeSeconds });
adapter.seek({ time: timeSeconds, suppressEvents: true });
} catch (err) {
// ignore seek bootstrap failures
swallow("runtime.init.site9", err);
Expand Down Expand Up @@ -2064,10 +2069,14 @@ export function initSandboxRuntimeModular(): void {
syncMediaForCurrentState();
},
onStatePost: postState,
onDeterministicSeek: (timeSeconds) => {
onDeterministicSeek: (timeSeconds, options) => {
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && state.capturedTimeline) continue;
try {
adapter.seek({ time: Number(timeSeconds) || 0 });
adapter.seek({
time: Number(timeSeconds) || 0,
suppressEvents: options?.suppressEvents,
});
} catch (err) {
// ignore adapter failure
swallow("runtime.init.site11", err);
Expand Down Expand Up @@ -2351,20 +2360,22 @@ export function initSandboxRuntimeModular(): void {
timeline: RuntimeTimelineLike,
timeSeconds: number,
swallowLabel: string,
options?: RuntimeSeekOptions,
) => {
try {
const suppressEvents = options?.suppressEvents === true;
timeline.pause();
if (typeof timeline.totalTime === "function") {
timeline.totalTime(timeSeconds, false);
timeline.totalTime(timeSeconds, suppressEvents);
} else {
timeline.seek(timeSeconds, false);
timeline.seek(timeSeconds, suppressEvents);
}
} catch (err) {
swallow(swallowLabel, err);
}
};

const seekStandaloneRegisteredTimelines = (timeSeconds: number) => {
const seekStandaloneRegisteredTimelines = (timeSeconds: number, options?: RuntimeSeekOptions) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
const rootCompositionId =
resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null;
Expand All @@ -2386,7 +2397,7 @@ export function initSandboxRuntimeModular(): void {
? Math.min(duration, timeSeconds - start)
: timeSeconds - start,
);
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline");
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options);
}
};

Expand All @@ -2410,8 +2421,76 @@ export function initSandboxRuntimeModular(): void {
}
};

const seekTimelineAndAdapters = (t: number, opts?: { activateChildren?: boolean }) => {
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;

const gsapCallbackTweenCache = new WeakMap<RuntimeTimelineLike, boolean>();
const GSAP_CALLBACK_NAMES = [
"onStart",
"onUpdate",
"onComplete",
"onReverseComplete",
"onRepeat",
];

const readGsapDuration = (child: Record<string, unknown>, property: string): number | null => {
const getter = child[property];
if (typeof getter !== "function") return null;
try {
const value = Number(getter.call(child));
return Number.isFinite(value) ? value : null;
} catch (err) {
swallow("runtime.init.gsapCallbackDuration", err);
return null;
}
};

const hasZeroDurationCallbackTween = (timeline: RuntimeTimelineLike): boolean => {
const cached = gsapCallbackTweenCache.get(timeline);
if (cached != null) return cached;

if (!("getChildren" in timeline) || typeof timeline.getChildren !== "function") {
return false;
}

let children: unknown;
try {
children = timeline.getChildren(true, true, true);
} catch (err) {
swallow("runtime.init.gsapCallbackChildren", err);
gsapCallbackTweenCache.set(timeline, false);
return false;
}
if (!Array.isArray(children)) {
gsapCallbackTweenCache.set(timeline, false);
return false;
}

for (const child of children) {
if (!isObjectRecord(child) || !isObjectRecord(child.vars)) continue;
const hasCallback = GSAP_CALLBACK_NAMES.some(
(name) => typeof child.vars[name] === "function",
);
if (!hasCallback) continue;

const totalDuration = readGsapDuration(child, "totalDuration");
const duration = totalDuration ?? readGsapDuration(child, "duration");
if (duration != null && duration <= 0.000001) {
gsapCallbackTweenCache.set(timeline, true);
return true;
}
}

gsapCallbackTweenCache.set(timeline, false);
return false;
};

const seekTimelineAndAdapters = (
t: number,
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
) => {
const tl = state.capturedTimeline;
const suppressEvents = opts?.suppressEvents === true;
if (tl) {
// When rendering frame-by-frame (activateChildren=true), ensure all
// sibling timelines are unpaused before seeking the root. GSAP
Expand Down Expand Up @@ -2445,9 +2524,16 @@ export function initSandboxRuntimeModular(): void {
}
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(tlSeekTime, false);
tl.totalTime(tlSeekTime, suppressEvents);
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
// Preserve GSAP's forced-render nudge for root timelines without
// firing callbacks a second time. The first seek is the only
// eventful one; the follow-up nudges only refresh computed styles.
tl.totalTime(tlSeekTime + 0.001, true);
tl.totalTime(tlSeekTime, true);
}
} else {
tl.seek(tlSeekTime, false);
tl.seek(tlSeekTime, suppressEvents);
}
} catch (err) {
swallow("runtime.init.transport.seek", err);
Expand All @@ -2460,11 +2546,12 @@ export function initSandboxRuntimeModular(): void {
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
} else {
seekStandaloneRegisteredTimelines(t);
seekStandaloneRegisteredTimelines(t, opts);
}
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && tl) continue;
try {
adapter.seek({ time: t });
adapter.seek({ time: t, suppressEvents });
} catch (err) {
swallow("runtime.init.transport.adapter", err);
}
Expand Down Expand Up @@ -2791,7 +2878,7 @@ export function initSandboxRuntimeModular(): void {
postState(true);
};

player.renderSeek = (timeSeconds: number) => {
player.renderSeek = (timeSeconds: number, options?: RuntimeSeekOptions) => {
const quantized = quantizeTimeToFrame(
Math.max(0, Number(timeSeconds) || 0),
state.canonicalFps,
Expand All @@ -2801,7 +2888,10 @@ export function initSandboxRuntimeModular(): void {
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
seekTimelineAndAdapters(state.currentTime, { activateChildren: true });
seekTimelineAndAdapters(state.currentTime, {
activateChildren: true,
suppressEvents: options?.suppressEvents,
});
syncMediaForCurrentState();
colorGrading.redraw();
postState(true);
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/runtime/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,20 @@ describe("createRuntimePlayer", () => {
expect(deps.onRenderFrameSeek).toHaveBeenCalled();
});

it("can suppress timeline events during administrative render seeks", () => {
const timeline = createMockTimeline({ duration: 10 });
const deps = createMockDeps(timeline);
const player = createRuntimePlayer(deps);
const renderSeek = player.renderSeek as (
time: number,
options?: { suppressEvents?: boolean },
) => void;

renderSeek(5, { suppressEvents: true });

expect(timeline.totalTime).toHaveBeenCalledWith(5, true);
});

it("renderSeek rearms paused siblings and keeps them active for export frames", () => {
const { master, scene1, scene2, scene5 } = createNestedTimelineHarness();
const deps = createMockDeps(master);
Expand Down
Loading
Loading