Skip to content
Open
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
99 changes: 96 additions & 3 deletions packages/studio/src/hooks/gsapRuntimeKeyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";

interface RuntimeTween {
export interface RuntimeTween {
targets?: () => Element[];
vars?: Record<string, unknown>;
duration?: () => number;
startTime?: () => number;
invalidate?: () => RuntimeTween;
}

interface RuntimeTimeline {
export interface RuntimeTimeline {
getChildren?: (deep: boolean) => RuntimeTween[];
duration?: () => number;
time?: () => number;
invalidate?: () => RuntimeTimeline;
}

type Pct = { percentage: number; properties: Record<string, number | string> };
Expand Down Expand Up @@ -56,7 +58,9 @@ const FLAT_SKIP_KEYS = new Set([
"keyframes",
]);

function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTimeline> | null {
export function timelinesOf(
iframe: HTMLIFrameElement | null,
): Record<string, RuntimeTimeline> | null {
if (!iframe?.contentWindow) return null;
try {
return (
Expand Down Expand Up @@ -161,6 +165,95 @@ function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
};
}

export interface ResolvedRuntimeTween {
/** The live GSAP tween targeting the selector. */
tween: RuntimeTween;
/** The composition timeline that owns it. */
timeline: RuntimeTimeline;
}

/**
* Whether a tween's `vars` carry at least one of `channels` as an OWN property.
* Used to disambiguate co-located `set`s: an element can have separate
* `tl.set("#el",{x,y})` and `tl.set("#el",{rotation})` tweens, and a position
* patch must land on the {x,y} set — never the rotation-only one.
*/
function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: string[]): boolean {
if (!vars) return false;
for (const ch of channels) {
if (Object.prototype.hasOwnProperty.call(vars, ch)) return true;
}
return false;
}

/**
* Resolve the live tween targeting `selector` using the SAME all-timelines scan
* `readRuntimeKeyframes` uses, so read and write agree on "which tween". With
* `kind: "keyframe"` it skips zero-duration `set`s and prefers the tween whose
* range contains the playhead (matching the reader). With `kind: "set"` it picks
* the zero-duration `set`/hold instead. Returns null when none matches.
*
* `channels` disambiguates co-located `set`s (CHANNEL-BLIND otherwise): when
* provided with `kind: "set"`, a set carrying ONE of those channels wins, and a
* set carrying ONLY disjoint channels is skipped (so patching {x,y} never lands
* on a rotation-only set). With no channel-matching set, it falls back to the
* first matching set (back-compat). `channels` is ignored for `kind: "keyframe"`.
*/
export function resolveRuntimeTween(
iframe: HTMLIFrameElement | null,
selector: string,
kind: "keyframe" | "set",
compositionId?: string,
channels?: string[],
): ResolvedRuntimeTween | null {
const timelines = timelinesOf(iframe);
if (!timelines) return null;

let targetEl: Element | null = null;
try {
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
} catch {
return null;
}
if (!targetEl) return null;

const tlIds = compositionId
? [compositionId]
: Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");

const wantChannels = kind === "set" && channels && channels.length > 0 ? channels : null;

let first: ResolvedRuntimeTween | null = null;
let channelMatch: ResolvedRuntimeTween | null = null;
for (const tlId of tlIds) {
const timeline = timelines[tlId];
if (!timeline?.getChildren) continue;
const now = typeof timeline.time === "function" ? timeline.time() : null;
for (const tween of timeline.getChildren(true)) {
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
const isSet = !(dur > 0);
if (kind === "set" ? !isSet : isSet) continue;
if (wantChannels) {
if (varsCarryChannel(tween.vars, wantChannels)) {
if (channelMatch === null) channelMatch = { tween, timeline };
} else if (first === null) {
// A set carrying only disjoint channels: remember as last-resort
// fallback, but never prefer it over a channel-matching set.
first = { tween, timeline };
}
continue;
}
if (first === null) first = { tween, timeline };
if (kind === "keyframe" && now != null) {
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return { tween, timeline };
}
}
}
return channelMatch ?? first;
}

/**
* Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
* Returns tween-relative percentages; callers convert to clip-relative.
Expand Down
Loading
Loading