diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bdc0e2c3a..90ac794b5 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -410,23 +410,6 @@ export function initSandboxRuntimeModular(): void { return resolveStartForElement(element, fallback); }; - const findTimedClipAncestor = ( - element: HTMLElement, - rootComp: HTMLElement | null, - ): HTMLElement | null => { - let node = element.parentElement; - while (node) { - // rootComp may be null when no composition is mounted; the walk still - // terminates via `while (node)` — node === null is never true here. - if (node === rootComp) break; - if (node.hasAttribute("data-start")) { - return node; - } - node = node.parentElement; - } - return null; - }; - const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { @@ -1073,6 +1056,21 @@ export function initSandboxRuntimeModular(): void { const dur = String(rootDuration > 0 ? rootDuration : 1); const seen = new Set(); + // Only an AUTHORED clip (data-start already in the source, captured before + // we stamp anything) should suppress stamping its descendants. An animated + // scene container we auto-stamp below (e.g. an opacity-crossfaded scene) + // must NOT suppress its own animated children — otherwise those children + // never become timeline clips and that scene can't inline-expand. + const authoredTimed = new Set(document.querySelectorAll("[data-start]")); + const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => { + let node = element.parentElement; + while (node && node !== rootComp) { + if (authoredTimed.has(node)) return true; + node = node.parentElement; + } + return false; + }; + // Stamp GSAP-targeted elements if (state.capturedTimeline.getChildren) { try { @@ -1082,7 +1080,7 @@ export function initSandboxRuntimeModular(): void { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; if (target.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(target, rootComp)) continue; + if (hasAuthoredTimedAncestor(target)) continue; if (seen.has(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); @@ -1102,7 +1100,7 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLElement)) continue; if (el === rootComp) continue; if (el.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(el, rootComp)) continue; + if (hasAuthoredTimedAncestor(el)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; seen.add(el); @@ -1439,6 +1437,21 @@ export function initSandboxRuntimeModular(): void { }; // fallow-ignore-next-line complexity + // Whether a timed clip participates in normal flow (static/relative/sticky). + // In-flow clips must leave the flow when hidden — `visibility:hidden` reserves + // their layout box, so a split sibling would stack below the active half + // instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper, + // and avoids disturbing absolute media playback). Computed once per element. + const timedClipInFlow = new WeakMap(); + const isTimedClipInFlow = (el: HTMLElement): boolean => { + const cached = timedClipInFlow.get(el); + if (cached !== undefined) return cached; + const pos = window.getComputedStyle(el).position; + const inFlow = pos === "static" || pos === "relative" || pos === "sticky"; + timedClipInFlow.set(el, inFlow); + return inFlow; + }; + const syncMediaForCurrentState = () => { const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { const compositionRoot = element.closest("[data-composition-id]"); @@ -1544,6 +1557,11 @@ export function initSandboxRuntimeModular(): void { if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); } + if (isVisibleNow) { + if (timedClipInFlow.get(rawNode)) rawNode.style.removeProperty("display"); + } else if (isTimedClipInFlow(rawNode)) { + rawNode.style.display = "none"; + } } }; diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 06bb7155e..a0f3dedcb 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -508,6 +508,28 @@ describe("splitElementInHtml", () => { expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false); }); + it("splits a GSAP element with no authored timing using fallback timing", () => { + // #title has no data-start/data-duration (GSAP-driven); the store supplies the range. + const gsapSource = `

Hi

`; + const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", { + start: 0, + duration: 6, + }); + expect(result.matched).toBe(true); + // original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined) + const original = result.html.match(/]*\bid="title"[^>]*>/)![0]; + expect(original).toContain('data-start="0"'); + expect(original).toContain('data-duration="2"'); + const clone = result.html.match(/]*\bid="title-split"[^>]*>/)![0]; + expect(clone).toContain('data-start="2"'); + expect(clone).toContain('data-duration="4"'); + }); + + it("still rejects a no-timing element when no fallback timing is given", () => { + const gsapSource = `

Hi

`; + expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false); + }); + it("adjusts media playback-start for the second half", () => { const mediaSource = source.replace( 'id="box" class="clip" data-start="1" data-duration="6"', diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index bfec5eb4c..57cdc6cfc 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -381,12 +381,23 @@ export function splitElementInHtml( target: SourceMutationTarget, splitTime: number, newId: string, + fallbackTiming?: { start: number; duration: number }, ): SplitElementResult { const { document, wrappedFragment } = parseSourceDocument(source); const el = findTargetElement(document, target); if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null }; - const { start, duration, usesDataEnd } = resolveElementTiming(el); + const timing = resolveElementTiming(el); + const { usesDataEnd } = timing; + let { start, duration } = timing; + // GSAP-animated elements carry their timing in the script, not in data-* attrs, + // so the source has no authored duration. Fall back to the store's (GSAP-derived) + // range — the runtime windows visibility off data-start/data-duration regardless + // of class, so stamping both halves below makes each half show only in its window. + if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) { + start = fallbackTiming.start; + duration = fallbackTiming.duration; + } if (duration <= 0 || splitTime <= start || splitTime >= start + duration) { return { html: source, matched: false, newId: null }; } @@ -405,6 +416,9 @@ export function splitElementInHtml( const clone = el.cloneNode(true) as HTMLElement; clone.setAttribute("id", newId); clone.removeAttribute("data-hf-id"); + // Descendants carry their own data-hf-id; leaving them duplicates the id of + // every nested node (e.g. an inner ), so strip them on the clone too. + for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id"); clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); setElementDuration(clone, splitTime, secondDuration, usesDataEnd); @@ -433,7 +447,9 @@ export function splitElementInHtml( duplicateCssRulesForId(document, originalId, newId); } - // Trim the original element's duration + // Trim the original element's duration. A GSAP element had no data-start; stamp + // it so the runtime windows the first half (visibility selects on [data-start]). + el.setAttribute("data-start", String(Math.round(start * 1000) / 1000)); setElementDuration(el, start, firstDuration, usesDataEnd); // Insert clone after original diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 229ddc1ff..a739fe36e 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -24,6 +24,7 @@ import { type UnsafeMutationValue, } from "../helpers/finiteMutation.js"; import type { GsapAnimation } from "../../parsers/gsapSerialize.js"; +import { classifyPropertyGroup } from "../../parsers/gsapConstants.js"; import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js"; import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js"; import { @@ -289,6 +290,18 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe return stripped; } +// A studio path-offset (--hf-studio-offset / data-hf-studio-path-offset) and a GSAP +// position tween both drive translate — keeping both stacks the offsets (a gesture or +// drag recorded over a stale offset plays shoved off-position). When a committed tween +// writes a position property, the tween owns position, so the stale offset must go. +function keyframesWritePosition( + keyframes: Array<{ properties: Record }>, +): boolean { + return keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), + ); +} + function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined { if (!kfs) return undefined; for (let i = kfs.keyframes.length - 1; i >= 0; i--) { @@ -431,6 +444,24 @@ type GsapMutationRequest = cp1?: { x: number; y: number }; cp2?: { x: number; y: number }; } + | { + type: "update-motion-path-point"; + animationId: string; + pointIndex: number; + x: number; + y: number; + } + | { type: "add-motion-path-point"; animationId: string; index: number; x: number; y: number } + | { type: "remove-motion-path-point"; animationId: string; index: number } + | { + type: "add-motion-path"; + targetSelector: string; + position: number; + duration: number; + x: number; + y: number; + ease?: string; + } | { type: "remove-arc-path"; animationId: string } | { type: "add-with-keyframes"; @@ -498,6 +529,24 @@ type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +// Mutations that can change a position tween's first keyframe (value/existence/timing) +// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards. +const HOLD_SYNC_MUTATION_TYPES = new Set([ + "add-keyframe", + "update-keyframe", + "remove-keyframe", + "remove-all-keyframes", + "add-with-keyframes", + "replace-with-keyframes", + "convert-to-keyframes", + "materialize-keyframes", + "update-motion-path-point", + "add-motion-path-point", + "remove-motion-path-point", + "delete", + "delete-all-for-selector", +]); + async function executeGsapMutation( body: GsapMutationRequest, block: NonNullable>, @@ -517,6 +566,10 @@ async function executeGsapMutation( unrollDynamicAnimations, setArcPathInScript, updateArcSegmentInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, @@ -680,10 +733,39 @@ async function executeGsapMutation( ...(body.cp2 ? { cp2: body.cp2 } : {}), }); } + case "update-motion-path-point": { + return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, { + x: body.x, + y: body.y, + }); + } + case "add-motion-path-point": { + return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, { + x: body.x, + y: body.y, + }); + } + case "remove-motion-path-point": { + return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index); + } + case "add-motion-path": { + const result = addMotionPathToScript( + block.scriptText, + body.targetSelector, + body.position, + body.duration, + { x: body.x, y: body.y }, + body.ease, + ); + return result.script; + } case "remove-arc-path": { return removeArcPathFromScript(block.scriptText, body.animationId); } case "add-with-keyframes": { + if (keyframesWritePosition(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationWithKeyframesToScript( block.scriptText, body.targetSelector, @@ -695,6 +777,9 @@ async function executeGsapMutation( return result.script; } case "replace-with-keyframes": { + if (keyframesWritePosition(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const script = removeAnimationFromScript(block.scriptText, body.animationId); const added = addAnimationWithKeyframesToScript( script, @@ -970,11 +1055,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { target?: { id?: string; selector?: string; selectorIndex?: number }; splitTime?: number; newId?: string; + elementStart?: number; + elementDuration?: number; }>(c); if ("error" in parsed) return parsed.error; if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) { return c.json({ error: "target, splitTime, and newId required" }, 400); } + const fallbackTiming = + typeof parsed.body.elementStart === "number" && + typeof parsed.body.elementDuration === "number" + ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } + : undefined; let originalContent: string; try { @@ -987,6 +1079,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { parsed.target, parsed.body.splitTime, parsed.body.newId, + fallbackTiming, ); if (!result.matched) { return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath }); @@ -1230,7 +1323,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const result = await executeGsapMutation(body, block, respond); if (result instanceof Response) return result; - const newScript = typeof result === "string" ? result : result.script; + let newScript = typeof result === "string" ? result : result.script; + // Keep the "hold before first keyframe" sets in sync after any mutation that can + // change a position tween's first keyframe or its existence. Without it, an + // element snaps to its CSS base before the tween starts instead of holding its + // first keyframe (the universal NLE behavior). + if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) { + const parser = await loadGsapParser(); + newScript = parser.syncPositionHoldsBeforeKeyframes(newScript); + } const changed = newScript !== block.scriptText; const newHtml = changed ? block.replaceScript(newScript) : html; let backupPath: string | null = null;