Skip to content

Commit 39ad00b

Browse files
committed
feat(studio): runtime hooks — global time compiler + keyframe runtime
Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper.
1 parent 6b63ca1 commit 39ad00b

8 files changed

Lines changed: 579 additions & 52 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createThreeAdapter } from "./adapters/three";
99
import { createTypegpuAdapter } from "./adapters/typegpu";
1010
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
1111
import { createWaapiAdapter } from "./adapters/waapi";
12-
import { readElementPlaybackRate, refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
12+
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
1313
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
1414
import { createPickerModule } from "./picker";
1515
import { createRuntimePlayer } from "./player";
@@ -1000,6 +1000,66 @@ export function initSandboxRuntimeModular(): void {
10001000
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
10011001
},
10021002
});
1003+
// Stamp data-start / data-duration on GSAP-targeted elements that lack
1004+
// them so the Studio timeline can discover individual animated elements.
1005+
// Skip elements whose ancestor already carries timing — stamping them
1006+
// would override the parent's clip visibility and cause preview/render
1007+
// parity drift.
1008+
{
1009+
const rootComp = resolveRootCompositionElement();
1010+
const rootDuration = boundDuration > 0 ? boundDuration : 0;
1011+
const dur = String(rootDuration > 0 ? rootDuration : 1);
1012+
const seen = new Set<Element>();
1013+
1014+
const hasTimedAncestor = (el: HTMLElement): boolean => {
1015+
let cursor = el.parentElement;
1016+
while (cursor) {
1017+
if (cursor.hasAttribute("data-start")) return true;
1018+
if (cursor === rootComp) return false;
1019+
cursor = cursor.parentElement;
1020+
}
1021+
return false;
1022+
};
1023+
1024+
// Stamp GSAP-targeted elements
1025+
if (state.capturedTimeline.getChildren) {
1026+
try {
1027+
for (const child of state.capturedTimeline.getChildren(true)) {
1028+
if (typeof child.targets !== "function") continue;
1029+
for (const target of child.targets()) {
1030+
if (!(target instanceof HTMLElement)) continue;
1031+
if (target === rootComp) continue;
1032+
if (target.hasAttribute("data-start")) continue;
1033+
if (hasTimedAncestor(target)) continue;
1034+
if (seen.has(target)) continue;
1035+
seen.add(target);
1036+
target.setAttribute("data-start", "0");
1037+
target.setAttribute("data-duration", dur);
1038+
}
1039+
}
1040+
} catch {
1041+
/* timeline access guard */
1042+
}
1043+
}
1044+
1045+
// Stamp all ID'd children of the composition root so they appear
1046+
// in the timeline even without animations. Enables selecting and
1047+
// adding animations from the design panel on a blank canvas.
1048+
if (rootComp instanceof HTMLElement) {
1049+
for (const el of rootComp.querySelectorAll("[id]")) {
1050+
if (!(el instanceof HTMLElement)) continue;
1051+
if (el === rootComp) continue;
1052+
if (el.hasAttribute("data-start")) continue;
1053+
if (hasTimedAncestor(el)) continue;
1054+
if (seen.has(el)) continue;
1055+
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
1056+
seen.add(el);
1057+
el.setAttribute("data-start", "0");
1058+
el.setAttribute("data-duration", dur);
1059+
}
1060+
}
1061+
}
1062+
10031063
// (Re-)probe all already-bound media elements against the new timeline.
10041064
// Clear the cache first so elements probed against a prior timeline get fresh keyframes.
10051065
for (const el of metadataBoundMedia) {
@@ -1356,7 +1416,6 @@ export function initSandboxRuntimeModular(): void {
13561416
const mediaStart =
13571417
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
13581418
0;
1359-
const playbackRate = readElementPlaybackRate(element);
13601419
const hostRemaining =
13611420
context.inheritedStart != null &&
13621421
context.inheritedDuration != null &&
@@ -1365,7 +1424,7 @@ export function initSandboxRuntimeModular(): void {
13651424
: null;
13661425
const sourceDuration =
13671426
Number.isFinite(element.duration) && element.duration > mediaStart
1368-
? Math.max(0, (element.duration - mediaStart) / playbackRate)
1427+
? Math.max(0, element.duration - mediaStart)
13691428
: null;
13701429
if (sourceDuration != null && hostRemaining != null) {
13711430
return Math.min(sourceDuration, hostRemaining);
@@ -1758,28 +1817,27 @@ export function initSandboxRuntimeModular(): void {
17581817
postState(true);
17591818
};
17601819

1761-
let buildListenerPending = false;
1762-
17631820
maybePublishRenderReady = () => {
1764-
if (!externalCompositionsReady) {
1821+
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
17651822
window.__renderReady = false;
17661823
return;
17671824
}
1768-
if (window.__hfTimelinesBuilding) {
1769-
window.__renderReady = false;
1770-
if (!buildListenerPending) {
1771-
buildListenerPending = true;
1772-
const onBuilt = () => {
1773-
buildListenerPending = false;
1774-
maybePublishRenderReady();
1775-
};
1776-
window.addEventListener("hf-timelines-built", onBuilt, { once: true });
1777-
}
1778-
return;
1779-
}
17801825
publishRenderReadyAfterTimelineBinding();
17811826
};
17821827

1828+
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
1829+
// active, composition scripts queue tl.to() calls instead of executing them
1830+
// synchronously. Wait for the "hf-timelines-built" event before the first
1831+
// binding attempt so the transport clock receives the finished timeline
1832+
// duration instead of permanently publishing duration=0.
1833+
if (window.__hfTimelinesBuilding) {
1834+
window.__renderReady = false;
1835+
const onTimelinesBuilt = () => {
1836+
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
1837+
maybePublishRenderReady();
1838+
};
1839+
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
1840+
}
17831841
maybePublishRenderReady();
17841842

17851843
// When the bundler inlines compositions, data-composition-src is removed so
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
3+
import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
4+
5+
const PROPERTY_DEFAULTS: Record<string, number> = {
6+
opacity: 1,
7+
x: 0,
8+
y: 0,
9+
scale: 1,
10+
scaleX: 1,
11+
scaleY: 1,
12+
rotation: 0,
13+
width: 100,
14+
height: 100,
15+
};
16+
17+
type CommitFn = (
18+
selection: DomEditSelection,
19+
mutation: Record<string, unknown>,
20+
options: {
21+
label: string;
22+
coalesceKey?: string;
23+
softReload?: boolean;
24+
skipReload?: boolean;
25+
},
26+
) => Promise<void>;
27+
28+
export async function commitKeyframeAtTimeImpl(
29+
selection: DomEditSelection,
30+
absoluteTime: number,
31+
animations: GsapAnimation[],
32+
properties: Record<string, number | string>,
33+
commitMutation: CommitFn,
34+
): Promise<void> {
35+
const selector = selection.id ? `#${selection.id}` : selection.selector;
36+
if (!selector) return;
37+
38+
const tween = findTweenAtTime(absoluteTime, animations, selector);
39+
if (tween) {
40+
const pct = absoluteToPercentageForAnimation(absoluteTime, tween);
41+
if (pct === null) return;
42+
43+
const hasExplicitKeyframes = !!tween.keyframes && tween.keyframes.keyframes.length > 0;
44+
if (!hasExplicitKeyframes) {
45+
await commitMutation(
46+
selection,
47+
{ type: "convert-to-keyframes", animationId: tween.id },
48+
{ label: "Convert to keyframes", skipReload: true },
49+
);
50+
}
51+
52+
const backfillDefaults: Record<string, number | string> = {};
53+
for (const key of Object.keys(properties)) {
54+
backfillDefaults[key] = PROPERTY_DEFAULTS[key] ?? 0;
55+
}
56+
57+
await commitMutation(
58+
selection,
59+
{
60+
type: "add-keyframe",
61+
animationId: tween.id,
62+
percentage: pct,
63+
properties,
64+
backfillDefaults,
65+
},
66+
{
67+
label: `Add keyframe at ${Math.round(absoluteTime * 100) / 100}s`,
68+
coalesceKey: `keyframe:${tween.id}:${pct}`,
69+
softReload: true,
70+
},
71+
);
72+
} else {
73+
const defaultDuration = 0.5;
74+
await commitMutation(
75+
selection,
76+
{
77+
type: "add-with-keyframes" as const,
78+
targetSelector: selector,
79+
position: absoluteTime,
80+
duration: defaultDuration,
81+
keyframes: [
82+
{ percentage: 0, properties },
83+
{ percentage: 100, properties },
84+
],
85+
},
86+
{
87+
label: `New animation at ${Math.round(absoluteTime * 100) / 100}s`,
88+
softReload: true,
89+
},
90+
);
91+
}
92+
}

packages/studio/src/hooks/gsapRuntimeKeyframes.ts

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -126,45 +126,96 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
126126

127127
for (const timeline of Object.values(timelines)) {
128128
if (!timeline?.getChildren) continue;
129+
const tlDuration = typeof timeline.duration === "function" ? timeline.duration() : 0;
130+
129131
for (const tween of timeline.getChildren(true)) {
130132
if (!tween.targets || !tween.vars) continue;
131133
const vars = tween.vars;
132-
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
133134

134-
const kfObj = vars.keyframes as Record<string, unknown>;
135-
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
136-
[];
137-
let easeEach: string | undefined;
135+
if (vars.keyframes && typeof vars.keyframes === "object") {
136+
const kfObj = vars.keyframes as Record<string, unknown>;
137+
const keyframes: Array<{
138+
percentage: number;
139+
properties: Record<string, number | string>;
140+
}> = [];
141+
let easeEach: string | undefined;
142+
143+
for (const [key, val] of Object.entries(kfObj)) {
144+
if (key === "easeEach") {
145+
if (typeof val === "string") easeEach = val;
146+
continue;
147+
}
148+
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
149+
if (!pctMatch || !val || typeof val !== "object") continue;
150+
const percentage = parseFloat(pctMatch[1]);
151+
const properties: Record<string, number | string> = {};
152+
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
153+
if (pk === "ease") continue;
154+
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
155+
else if (typeof pv === "string") properties[pk] = pv;
156+
}
157+
if (Object.keys(properties).length > 0) {
158+
keyframes.push({ percentage, properties });
159+
}
160+
}
138161

139-
for (const [key, val] of Object.entries(kfObj)) {
140-
if (key === "easeEach") {
141-
if (typeof val === "string") easeEach = val;
162+
if (keyframes.length > 0) {
163+
keyframes.sort((a, b) => a.percentage - b.percentage);
164+
for (const target of tween.targets()) {
165+
const id = (target as HTMLElement).id;
166+
if (id && !result.has(id)) {
167+
result.set(id, { keyframes, easeEach });
168+
}
169+
}
142170
continue;
143171
}
144-
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
145-
if (!pctMatch || !val || typeof val !== "object") continue;
146-
const percentage = parseFloat(pctMatch[1]);
147-
const properties: Record<string, number | string> = {};
148-
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
149-
if (pk === "ease") continue;
150-
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
151-
else if (typeof pv === "string") properties[pk] = pv;
152-
}
153-
if (Object.keys(properties).length > 0) {
154-
keyframes.push({ percentage, properties });
155-
}
156172
}
157173

158-
if (keyframes.length === 0) continue;
159-
keyframes.sort((a, b) => a.percentage - b.percentage);
174+
// Flat tweens: synthesize start + end keyframe entries
175+
if (!tlDuration || tlDuration <= 0) continue;
176+
const tweenStart = typeof tween.startTime === "function" ? tween.startTime() : undefined;
177+
if (typeof tweenStart !== "number" || !Number.isFinite(tweenStart)) continue;
178+
const tweenDur = typeof tween.duration === "function" ? tween.duration() : 0;
179+
180+
const startPct = Math.round((tweenStart / tlDuration) * 1000) / 10;
181+
const endPct =
182+
tweenDur > 0 ? Math.round(((tweenStart + tweenDur) / tlDuration) * 1000) / 10 : startPct;
183+
const properties: Record<string, number | string> = {};
184+
const skip = new Set([
185+
"ease",
186+
"duration",
187+
"delay",
188+
"stagger",
189+
"motionPath",
190+
"overwrite",
191+
"immediateRender",
192+
"onComplete",
193+
"onUpdate",
194+
"onStart",
195+
]);
196+
for (const [k, v] of Object.entries(vars)) {
197+
if (skip.has(k)) continue;
198+
if (typeof v === "number") properties[k] = Math.round(v * 1000) / 1000;
199+
else if (typeof v === "string") properties[k] = v;
200+
}
201+
if (Object.keys(properties).length === 0) continue;
160202

161203
for (const target of tween.targets()) {
162204
const id = (target as HTMLElement).id;
163-
if (id && !result.has(id)) {
164-
result.set(id, { keyframes, easeEach });
205+
if (!id) continue;
206+
const existing = result.get(id);
207+
const entries = existing ?? { keyframes: [] };
208+
entries.keyframes.push({ percentage: startPct, properties });
209+
if (endPct !== startPct) {
210+
entries.keyframes.push({ percentage: endPct, properties });
165211
}
212+
if (!existing) result.set(id, entries);
166213
}
167214
}
168215
}
216+
217+
for (const entry of result.values()) {
218+
entry.keyframes.sort((a, b) => a.percentage - b.percentage);
219+
}
169220
return result;
170221
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export function previewKeyframeChange(
2+
iframe: HTMLIFrameElement | null,
3+
selector: string,
4+
properties: Record<string, number | string>,
5+
): boolean {
6+
if (!iframe?.contentWindow) return false;
7+
try {
8+
const gsap = (
9+
iframe.contentWindow as unknown as {
10+
gsap?: { set: (target: string, vars: Record<string, number | string>) => void };
11+
}
12+
).gsap;
13+
if (!gsap?.set) return false;
14+
gsap.set(selector, properties);
15+
return true;
16+
} catch {
17+
return false;
18+
}
19+
}

0 commit comments

Comments
 (0)