/** * Read GSAP keyframe data from the live runtime in the preview iframe. * Used to discover dynamic keyframes that the AST parser can't resolve * (data-driven loops, fetched values, computed selectors). * * Keyframe percentages returned here are TWEEN-RELATIVE (0–100 within the * tween), matching the static parser. Callers convert to clip-relative via * `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes` * does that conversion itself when given a `clipById` map. */ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn"; import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared"; import { roundTo3 } from "../utils/rounding"; /** * A GSAP tween's `vars` object — intentionally open: it mixes channel values * (numbers), easing (strings), flags (booleans), nested keyframes (objects) and * callbacks. Named so call sites read as "GSAP config", not an untyped escape hatch. */ export type GsapVars = Record; export interface RuntimeTween { targets?: () => Element[]; vars?: GsapVars; duration?: () => number; startTime?: () => number; invalidate?: () => RuntimeTween; /** Remove this tween from its parent timeline (GSAP `kill()`). */ kill?: () => void; /** The timeline this tween lives in — used to re-insert a rebuilt tween. */ parent?: RuntimeTimeline; } export interface RuntimeTimeline { getChildren?: (deep: boolean) => RuntimeTween[]; duration?: () => number; time?: () => number; invalidate?: () => RuntimeTimeline; /** Add a tween at an absolute position — used to rebuild a keyframe tween in place. */ to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween; } type Pct = { percentage: number; properties: Record }; export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig }; export interface RuntimeKeyframeEntry { keyframes: Pct[]; easeEach?: string; /** Present when the live tween uses motionPath — drives the Arc Motion panel. */ arcPath?: ArcPathConfig; /** Absolute start time of the source tween (seconds). */ tweenStart: number; /** Duration of the source tween (seconds). */ tweenDuration: number; } /** Clip start/duration per element id, to convert tween-relative % to clip-relative. */ export type ClipDims = Map; const FLAT_SKIP_KEYS = new Set([ "ease", "duration", "delay", "stagger", "motionPath", "overwrite", "immediateRender", "onComplete", "onUpdate", "onStart", "keyframes", ]); function timelinesOf(iframe: HTMLIFrameElement | null): Record | null { if (!iframe?.contentWindow) return null; try { return ( (iframe.contentWindow as unknown as { __timelines?: Record }) .__timelines ?? null ); } catch { return null; } } function isXY(p: unknown): p is { x: number; y: number } { return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number"; } /** * A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl. * the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`). * These sit before the real keyframed tween and otherwise shadow it — `readTween` * would fall back to a degenerate 2-point flat path from the set's values, hiding * the actual multi-keyframe motion. `!(duration > 0)` also rejects NaN durations. */ function isZeroDurationSet(duration: number): boolean { return !(duration > 0); } /** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */ function coordsFromMotionPath(mp: unknown): { coords: Array<{ x: number; y: number }>; curviness: number; autoRotate: boolean | number; isCubic: boolean; } | null { if (!mp || typeof mp !== "object") return null; const obj = mp as Record; const pathVal = Array.isArray(mp) ? mp : obj.path; if (!Array.isArray(pathVal)) return null; const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y })); if (coords.length < 2) return null; const curviness = typeof obj.curviness === "number" ? obj.curviness : 1; const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true; return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" }; } /** Build an arcPath config from a live `vars.motionPath` value. */ export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined { const parsed = coordsFromMotionPath(mp); if (!parsed) return undefined; return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath; } function flatTweenKeyframes(vars: Record): Pct[] | null { const properties: Record = {}; for (const [k, v] of Object.entries(vars)) { if (FLAT_SKIP_KEYS.has(k)) continue; if (typeof v === "number") properties[k] = roundTo3(v); else if (typeof v === "string") properties[k] = v; } if (Object.keys(properties).length === 0) return null; return [ { percentage: 0, properties }, { percentage: 100, properties }, ]; } /** Tween-relative keyframes + optional arcPath for one live tween, or null. */ function readTween(vars: Record): ReadTween | null { if (vars.keyframes && typeof vars.keyframes === "object") { const parsed = parsePercentageKeyframes(vars.keyframes as Record); if (parsed) return parsed; } const mp = coordsFromMotionPath(vars.motionPath); if (mp) { const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic); if (shape) { const n = shape.waypoints.length; const keyframes = shape.waypoints.map((wp, i) => ({ percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0, properties: { x: wp.x, y: wp.y }, })); return { keyframes, arcPath: shape.arcPath }; } } const flat = flatTweenKeyframes(vars); return flat ? { keyframes: flat } : null; } function matchesElement(tween: RuntimeTween, el: Element): boolean { if (!tween.targets) return false; for (const t of tween.targets()) { if (t === el || (el.id && (t as Element).id === el.id)) return true; } return false; } function tweenTiming(tween: RuntimeTween): { start: number; duration: number } { const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0; const rawDur = typeof tween.duration === "function" ? tween.duration() : 0; return { start: Number.isFinite(rawStart) ? rawStart : 0, duration: Number.isFinite(rawDur) ? rawDur : 0, }; } 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 | undefined, channels: string[]): boolean { if (!vars) return false; for (const ch of channels) { if (Object.prototype.hasOwnProperty.call(vars, ch)) return true; } return false; } /** * Like `varsCarryChannel` but for a keyframe tween: the channels live inside the * keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object * form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`). */ function keyframeVarsCarryChannel( vars: Record | undefined, channels: string[], ): boolean { const kf = vars?.keyframes; if (!kf || typeof kf !== "object") return false; const steps = Array.isArray(kf) ? kf : Object.values(kf); return steps.some( (step) => step != null && typeof step === "object" && channels.some((ch) => Object.prototype.hasOwnProperty.call(step, ch)), ); } /** * 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"`. */ // fallow-ignore-next-line complexity export function resolveRuntimeTween( iframe: HTMLIFrameElement | null, selector: string, kind: "keyframe" | "set", compositionId?: string, channels?: string[], // fallow-ignore-next-line code-duplication ): 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"); // Channels disambiguate co-located tweens for BOTH kinds: a `set` carries them as // own vars props, a keyframe tween carries them inside its keyframe steps. An // element can have a rotation keyframe tween AND a position keyframe tween; a // rotation edit must land on the former. The reader passes no channels, so its // playhead-containment path below is unchanged. const wantChannels = channels && channels.length > 0 ? channels : null; let first: ResolvedRuntimeTween | null = null; // fallow-ignore-next-line code-duplication 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) { const carries = kind === "set" ? varsCarryChannel(tween.vars, wantChannels) : keyframeVarsCarryChannel(tween.vars, wantChannels); if (carries) { if (channelMatch === null) channelMatch = { tween, timeline }; } else if (first === null) { // A tween carrying only disjoint channels: remember as last-resort // fallback, but never prefer it over a channel-matching one. 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; } /** Whether a read carries at least one of `channels` as a keyframe property. */ function readCarriesChannel(read: ReadTween, channels: string[]): boolean { return read.keyframes.some((kf) => channels.some((c) => kf.properties[c] != null)); } /** * Read keyframes (incl. motionPath arcs) for one selector from the live timeline. * Returns tween-relative percentages; callers convert to clip-relative. * * `requireChannels` restricts the scan to tweens whose read carries one of those * properties — e.g. the motion-path overlay passes `["x","y"]` so it never picks * up a co-located size/scale tween (which has no x/y and would blank the path * whenever the playhead sits in that tween's range but outside the position * tween's). Omitted → any keyframed tween qualifies (back-compat). */ // fallow-ignore-next-line complexity export function readRuntimeKeyframes( iframe: HTMLIFrameElement | null, selector: string, compositionId?: string, requireChannels?: string[], // fallow-ignore-next-line code-duplication ): ReadTween | 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; // Search the element's OWN composition timeline. With inlined subcompositions the // preview has multiple timelines (one per composition), and the element belongs to // exactly one — so we can't assume the first key (order isn't stable across soft // reloads, which delete+re-add the rebuilt key). Scan every timeline for tweens // targeting this element; only its composition's timeline matches. An explicit // compositionId still pins the search. (`__proxied` and other non-timeline markers // are skipped by the getChildren guard.) const tlIds = compositionId ? [compositionId] : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function"); if (tlIds.length === 0) return null; // The element can have MORE THAN ONE keyframed tween at disjoint time ranges // (e.g. two non-overlapping gesture recordings → two separate `to()`s). The // overlay must draw the segment under the PLAYHEAD, not blindly the first one // — otherwise recording a second gesture leaves the path stuck on the first. // fallow-ignore-next-line code-duplication let firstRead: ReadTween | null = null; for (const tlId of tlIds) { const timeline = timelines[tlId]; if (!timeline?.getChildren) continue; const now = typeof timeline.time === "function" ? timeline.time() : null; // fallow-ignore-next-line code-duplication for (const tween of timeline.getChildren(true)) { if (!tween.vars || !matchesElement(tween, targetEl)) continue; const dur = typeof tween.duration === "function" ? tween.duration() : 0; if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) const read = readTween(tween.vars); if (!read) continue; if (requireChannels && !readCarriesChannel(read, requireChannels)) continue; if (firstRead === null) firstRead = read; // Prefer the tween whose [start, start+dur] contains the playhead. if (now != null) { const start = typeof tween.startTime === "function" ? tween.startTime() : 0; if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read; } } } // Playhead outside every tween's range (or timeline has no clock): the element // still has motion, so fall back to the first keyframed tween. return firstRead; } /** * Whether the live timeline has at least one NON-HOLD tween (non-zero duration, * not the studio position-hold `set`) targeting `selector`. Stricter than a * truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing * tween, so it can't distinguish a real animation from a leftover hold/marker. * The drag's stale-parse guard needs this exact distinction — after a delete-all * only a hold may remain, and resurrecting the deleted tween from the stale parse * must be avoided. * When `channels` is provided, only tweens carrying one of those keyframe * properties count as non-hold motion (e.g. position channels), so a sibling * rotation/scale tween doesn't make a static position hold enter the keyframe * branch. */ // fallow-ignore-next-line complexity export function hasNonHoldTweenForElement( iframe: HTMLIFrameElement | null, selector: string, compositionId?: string, channels?: string[], ): boolean { const timelines = timelinesOf(iframe); if (!timelines) return false; const tlId = compositionId || Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function"); if (!tlId) return false; const timeline = timelines[tlId]; if (!timeline?.getChildren) return false; let targetEl: Element | null = null; try { targetEl = iframe?.contentDocument?.querySelector(selector) ?? null; } catch { return false; } if (!targetEl) return false; // fallow-ignore-next-line code-duplication for (const tween of timeline.getChildren(true)) { if (!tween.vars || !matchesElement(tween, targetEl)) continue; const dur = typeof tween.duration === "function" ? tween.duration() : 0; if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) const read = readTween(tween.vars); if (read && (!channels || readCarriesChannel(read, channels))) return true; } return false; } /** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */ function toClipRelative( keyframes: Pct[], tweenStart: number, tweenDuration: number, clip: { start: number; duration: number } | undefined, ): Pct[] { if (!clip || clip.duration <= 0) return keyframes; return keyframes.map((kf) => { const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage); return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 }; }); } function buildEntry( read: ReadTween, start: number, duration: number, clip: { start: number; duration: number } | undefined, ): RuntimeKeyframeEntry { return { keyframes: toClipRelative(read.keyframes, start, duration, clip), tweenStart: start, tweenDuration: duration, ...(read.easeEach ? { easeEach: read.easeEach } : {}), ...(read.arcPath ? { arcPath: read.arcPath } : {}), }; } /** Record one tween's keyframes under each target id (first-tween-per-id wins). */ function addScanEntry( result: Map, tween: RuntimeTween, clipById?: ClipDims, ): void { if (!tween.targets || !tween.vars) return; const { start, duration } = tweenTiming(tween); if (isZeroDurationSet(duration)) return; // skip hold/set tweens (see isZeroDurationSet) const read = readTween(tween.vars); if (!read) return; for (const target of tween.targets()) { const id = (target as HTMLElement).id; if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id))); } } /** * Scan every live tween, grouping keyframes by element id. Percentages are * tween-relative unless `clipById` is supplied, in which case each entry's * keyframes are converted to clip-relative. First keyframe-bearing tween per * element wins (the common single-primary-tween case). */ export function scanAllRuntimeKeyframes( iframe: HTMLIFrameElement | null, clipById?: ClipDims, ): Map { const result = new Map(); const timelines = timelinesOf(iframe); if (!timelines) return result; for (const timeline of Object.values(timelines)) { if (!timeline?.getChildren) continue; for (const tween of timeline.getChildren(true)) addScanEntry(result, tween, clipById); } return result; }