Skip to content

Commit 20ca775

Browse files
committed
fix(studio): keyframe drag + recording bug bash
21 fixes: capture GSAP base at drag start, translate:none before gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording seek, _auto flag for 100% keyframes, overlay flash fix, block edits during recording.
1 parent a8c1a94 commit 20ca775

12 files changed

Lines changed: 600 additions & 157 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -954,33 +954,13 @@ export function initSandboxRuntimeModular(): void {
954954
state.capturedTimeline.totalTime(seekTime, false);
955955
}
956956

957-
// Strip stale CSS offset artifacts from GSAP-targeted elements.
958-
// These leak into the HTML when the CSS offset path fires for a
959-
// GSAP-animated element (stale cache race). On reload, both the
960-
// offset and GSAP transform stack, doubling the visual position.
961-
const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]");
962-
if (staleEls.length > 0 && state.capturedTimeline.getChildren) {
963-
const tweenTargets = new Set<Element>();
964-
try {
965-
for (const child of state.capturedTimeline.getChildren(true)) {
966-
if (typeof child.targets === "function") {
967-
for (const t of child.targets()) tweenTargets.add(t);
968-
}
969-
}
970-
} catch {
971-
/* timeline access guard */
972-
}
973-
for (const el of staleEls) {
974-
if (!tweenTargets.has(el)) continue;
975-
const htmlEl = el as HTMLElement;
976-
htmlEl.removeAttribute("data-hf-studio-path-offset");
977-
htmlEl.removeAttribute("data-hf-studio-original-translate");
978-
htmlEl.removeAttribute("data-hf-studio-original-inline-translate");
979-
htmlEl.style.removeProperty("--hf-studio-offset-x");
980-
htmlEl.style.removeProperty("--hf-studio-offset-y");
981-
htmlEl.style.removeProperty("translate");
982-
}
983-
}
957+
// GSAP bakes the CSS `translate` into style.transform on seek.
958+
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
959+
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
960+
// directly here as well, since the wrapper may not be installed yet
961+
// during initial rebind (timing race on first load / soft reload).
962+
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
963+
if (typeof applyFn === "function") applyFn();
984964
}
985965
if (resolution.diagnostics) {
986966
postRuntimeMessage({
@@ -1002,25 +982,12 @@ export function initSandboxRuntimeModular(): void {
1002982
});
1003983
// Stamp data-start / data-duration on GSAP-targeted elements that lack
1004984
// 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.
1008985
{
1009986
const rootComp = resolveRootCompositionElement();
1010987
const rootDuration = boundDuration > 0 ? boundDuration : 0;
1011988
const dur = String(rootDuration > 0 ? rootDuration : 1);
1012989
const seen = new Set<Element>();
1013990

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-
1024991
// Stamp GSAP-targeted elements
1025992
if (state.capturedTimeline.getChildren) {
1026993
try {
@@ -1030,7 +997,6 @@ export function initSandboxRuntimeModular(): void {
1030997
if (!(target instanceof HTMLElement)) continue;
1031998
if (target === rootComp) continue;
1032999
if (target.hasAttribute("data-start")) continue;
1033-
if (hasTimedAncestor(target)) continue;
10341000
if (seen.has(target)) continue;
10351001
seen.add(target);
10361002
target.setAttribute("data-start", "0");
@@ -1050,7 +1016,6 @@ export function initSandboxRuntimeModular(): void {
10501016
if (!(el instanceof HTMLElement)) continue;
10511017
if (el === rootComp) continue;
10521018
if (el.hasAttribute("data-start")) continue;
1053-
if (hasTimedAncestor(el)) continue;
10541019
if (seen.has(el)) continue;
10551020
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
10561021
seen.add(el);

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,11 @@ export function patchElementInHtml(
195195
}
196196
break;
197197
case "text-content":
198-
if (op.value != null) htmlEl.textContent = op.value;
198+
if (op.value != null) {
199+
const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
200+
const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl;
201+
textTarget.textContent = op.value;
202+
}
199203
break;
200204
}
201205
}
@@ -219,6 +223,35 @@ export interface SplitElementResult {
219223
newId: string | null;
220224
}
221225

226+
function resolveElementTiming(el: Element): {
227+
start: number;
228+
duration: number;
229+
usesDataEnd: boolean;
230+
} {
231+
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
232+
const usesDataEnd = el.hasAttribute("data-end");
233+
const duration = usesDataEnd
234+
? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
235+
: parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
236+
return { start, duration, usesDataEnd };
237+
}
238+
239+
function setElementDuration(
240+
el: Element,
241+
start: number,
242+
duration: number,
243+
usesDataEnd: boolean,
244+
): void {
245+
if (usesDataEnd) {
246+
const endTime = String(Math.round((start + duration) * 1000) / 1000);
247+
el.setAttribute("data-end", endTime);
248+
el.removeAttribute("data-duration");
249+
} else {
250+
el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
251+
el.removeAttribute("data-end");
252+
}
253+
}
254+
222255
export function splitElementInHtml(
223256
source: string,
224257
target: SourceMutationTarget,
@@ -229,8 +262,7 @@ export function splitElementInHtml(
229262
const el = findTargetElement(document, target);
230263
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
231264

232-
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
233-
const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
265+
const { start, duration, usesDataEnd } = resolveElementTiming(el);
234266
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
235267
return { html: source, matched: false, newId: null };
236268
}
@@ -241,7 +273,7 @@ export function splitElementInHtml(
241273
const clone = el.cloneNode(true) as HTMLElement;
242274
clone.setAttribute("id", newId);
243275
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
244-
clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
276+
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
245277

246278
// Adjust media trim offset for the second half
247279
const playbackStartAttr = el.hasAttribute("data-playback-start")
@@ -251,15 +283,16 @@ export function splitElementInHtml(
251283
: null;
252284
if (playbackStartAttr) {
253285
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
254-
const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1;
286+
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
287+
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
255288
clone.setAttribute(
256289
playbackStartAttr,
257290
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
258291
);
259292
}
260293

261294
// Trim the original element's duration
262-
el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000));
295+
setElementDuration(el, start, firstDuration, usesDataEnd);
263296

264297
// Insert clone after original
265298
if (el.nextSibling) {

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
252252
groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
253253

254254
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
255+
if (!allowCanvasMovement) return;
255256
if (suppressNextOverlayMouseDownRef.current) {
256257
suppressNextOverlayMouseDownRef.current = false;
257258
suppressNextBoxMouseDownRef.current = false;
@@ -312,6 +313,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
312313
};
313314

314315
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
316+
if (!allowCanvasMovement) return;
315317
if (gestureRef.current || groupGestureRef.current) return;
316318
if (suppressNextBoxClickRef.current) {
317319
suppressNextBoxClickRef.current = false;
@@ -344,7 +346,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
344346
onPointerUp={gestures.onPointerUp}
345347
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
346348
>
347-
{hoverSelection && hoverRect && (
349+
{hoverSelection && hoverRect && compRect.width > 0 && (
348350
<div
349351
aria-hidden="true"
350352
data-dom-edit-hover-box="true"
@@ -374,7 +376,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
374376
})()}
375377
/>
376378
)}
377-
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
379+
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
378380
<>
379381
{groupOverlayItems.map((item) => (
380382
<div
@@ -408,7 +410,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
408410
/>
409411
</>
410412
)}
411-
{!hasGroupSelection && selection && overlayRect && (
413+
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
412414
<>
413415
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
414416
<div
@@ -485,6 +487,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
485487
</>
486488
)}
487489
{childRects.length > 0 &&
490+
compRect.width > 0 &&
488491
childRects.map((cr, i) => (
489492
<div
490493
key={i}

packages/studio/src/components/editor/manualEdits.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
240240
"renderSeek",
241241
);
242242
const wrappedTimelineSeek = wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "seek");
243+
wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "totalTime");
243244
const wrappedPlayerPlay = wrapPlayReapplyFunction(studioWin, studioWin.__player, "play");
244245
const wrappedTimelinePlay = wrapPlayReapplyFunction(studioWin, studioWin.__timeline, "play");
245246
const wrappedPlayerPause = wrapApplyAfterFunction(studioWin, studioWin.__player, "pause");
@@ -250,6 +251,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
250251
for (const timeline of Object.values(studioWin.__timelines ?? {})) {
251252
wrappedNamedTimelineSeek =
252253
wrapSeekReapplyFunction(studioWin, timeline, "seek") || wrappedNamedTimelineSeek;
254+
wrapSeekReapplyFunction(studioWin, timeline, "totalTime");
253255
wrappedNamedTimelinePlay =
254256
wrapPlayReapplyFunction(studioWin, timeline, "play") || wrappedNamedTimelinePlay;
255257
wrappedNamedTimelinePause =
@@ -268,6 +270,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
268270
if (typeof value === "object" && value !== null) {
269271
const tl = value as Record<string, unknown>;
270272
wrapSeekReapplyFunction(studioWin, tl, "seek");
273+
wrapSeekReapplyFunction(studioWin, tl, "totalTime");
271274
wrapPlayReapplyFunction(studioWin, tl, "play");
272275
wrapApplyAfterFunction(studioWin, tl, "pause");
273276
studioWin.__hfStudioManualEditsApply?.();

packages/studio/src/components/editor/manualEditsDom.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,25 @@ export function applyStudioPathOffsetDraft(
273273
): void {
274274
promoteInlineForTransform(element);
275275
writeStudioPathOffsetVars(element, offset, { updateBase: false });
276-
element.style.setProperty(
277-
"translate",
278-
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
279-
);
280-
stripGsapTranslateFromTransform(element);
276+
277+
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
278+
if (isGsapAnimated) {
279+
// For GSAP-animated elements: use gsap.set for positioning (the timeline
280+
// is paused during drag). Set translate:none explicitly to prevent
281+
// double-counting with the transform.
282+
element.style.setProperty("translate", "none");
283+
const win = element.ownerDocument.defaultView as
284+
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
285+
| null;
286+
win?.gsap?.set(element, { x: offset.x, y: offset.y });
287+
} else {
288+
// Non-GSAP elements: use CSS translate as before.
289+
element.style.setProperty(
290+
"translate",
291+
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
292+
);
293+
stripGsapTranslateFromTransform(element);
294+
}
281295
}
282296

283297
/* ── Box size apply ───────────────────────────────────────────────── */
@@ -505,6 +519,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
505519

506520
function reapplyPathOffsets(doc: Document): void {
507521
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
522+
// Skip elements where GSAP actively animates position — GSAP bakes the
523+
// CSS translate into its transform and sets translate: none every tick.
524+
// Stripping/restoring would oscillate against GSAP's rendering.
525+
if (gsapAnimatesProperty(el, "x", "y")) continue;
508526
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
509527
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
510528
if (x || y) {

packages/studio/src/components/editor/manualOffsetDrag.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,41 @@ export function createManualOffsetDragMember(input: {
232232
rect: ManualOffsetDragRect;
233233
}): ManualOffsetDragMemberResult {
234234
const initialOffset = readStudioPathOffset(input.element);
235+
input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
236+
input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
237+
238+
// Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path
239+
// needs the original (uncorrupted) GSAP position to compute the new keyframe value.
240+
const win = input.element.ownerDocument.defaultView as
241+
| (Window & {
242+
gsap?: { getProperty?: (el: Element, prop: string) => number };
243+
__timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
244+
})
245+
| null;
246+
const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
247+
const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
248+
input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
249+
input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
250+
251+
// Pause GSAP timelines during drag to prevent the tween from overwriting
252+
// the draft's gsap.set on every tick. Track which we paused to resume later.
253+
if (win?.__timelines) {
254+
const paused: string[] = [];
255+
for (const [id, tl] of Object.entries(win.__timelines)) {
256+
try {
257+
if (tl?.pause && !tl.paused?.()) {
258+
tl.pause();
259+
paused.push(id);
260+
}
261+
} catch {
262+
/* cross-origin guard */
263+
}
264+
}
265+
if (paused.length > 0) {
266+
input.element.setAttribute("data-hf-drag-paused-timelines", paused.join(","));
267+
}
268+
}
269+
235270
const initialPathOffset = captureStudioPathOffset(input.element);
236271
const gestureToken = beginStudioManualEditGesture(input.element);
237272
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
@@ -313,11 +348,35 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
313348
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
314349
for (const member of members) {
315350
restoreManualOffsetDragMember(member);
351+
resumeGsapTimelines(member.element);
316352
}
317353
}
318354

319355
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
320356
for (const member of members) {
321357
endStudioManualEditGesture(member.element, member.gestureToken);
358+
member.element.removeAttribute("data-hf-drag-initial-offset-x");
359+
member.element.removeAttribute("data-hf-drag-initial-offset-y");
360+
member.element.removeAttribute("data-hf-drag-gsap-base-x");
361+
member.element.removeAttribute("data-hf-drag-gsap-base-y");
362+
resumeGsapTimelines(member.element);
322363
}
323364
}
365+
366+
function resumeGsapTimelines(element: HTMLElement): void {
367+
const ids = element.getAttribute("data-hf-drag-paused-timelines");
368+
element.removeAttribute("data-hf-drag-paused-timelines");
369+
if (!ids) return;
370+
const win = element.ownerDocument.defaultView as
371+
| (Window & {
372+
__timelines?: Record<string, { pause?: () => void }>;
373+
__player?: { seek?: (t: number) => void; getTime?: () => number };
374+
})
375+
| null;
376+
if (!win) return;
377+
// Re-seek to the current time to restore the paused timeline's render state.
378+
// play() would start playback; pause() already stops. Seek re-renders at the
379+
// current position without starting playback.
380+
const t = win.__player?.getTime?.() ?? 0;
381+
win.__player?.seek?.(t);
382+
}

packages/studio/src/components/editor/useDomEditOverlayRects.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ export function useDomEditOverlayRects({
113113

114114
const update = () => {
115115
frame = requestAnimationFrame(update);
116-
if (rafPausedRef.current) return;
116+
if (rafPausedRef.current) {
117+
if (childRectsRef.current.length > 0) {
118+
childRectsRef.current = [];
119+
setChildRectsState([]);
120+
}
121+
return;
122+
}
117123

118124
const sel = selectionRef.current;
119125
const iframe = iframeRef.current;
@@ -143,7 +149,8 @@ export function useDomEditOverlayRects({
143149
resolvedElementRef as ResolvedElementRef,
144150
);
145151
if (el && isElementVisibleForOverlay(el)) {
146-
setOverlayRect(toOverlayRect(overlayEl, iframe, el));
152+
const nextRect = toOverlayRect(overlayEl, iframe, el);
153+
setOverlayRect(nextRect);
147154
const descendants = el.querySelectorAll("*");
148155
if (descendants.length > 0 && descendants.length <= 60) {
149156
const nextChildRects: OverlayRect[] = [];

0 commit comments

Comments
 (0)