feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)

* 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.

* 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.

* feat(studio): keyframe cache + commit hooks

Add hooks for keyframe cache population (tween → clip-relative %),
mutation dispatch, keyframe snapping, and audio beat detection.

* feat(studio): timeline UI — dopesheet diamonds + keyboard nav

Add dopesheet strip with diamond keyframe indicators, timeline property
rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate
(STUDIO_KEYFRAMES_ENABLED defaults to false).

* feat(studio): design panel — arc controls + ease curve + stagger

Add arc path controls (curviness slider, auto-rotate), motion path SVG
overlay, ease curve visualization, stagger controls, and expanded
animation card. Includes border-radius editor dependency from #1217.

* feat(studio): gesture recording core

Add gesture recording engine with RAF sampling, modifier key property
mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity),
Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay.

* 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.

* feat(studio): keyframe integration wiring + docs

Wire App.tsx recording orchestration, TimelineToolbar K/R buttons,
PropertyPanel per-property diamonds, shortcuts panel, toast
notifications, and keyframes guide documentation. All gated on
STUDIO_KEYFRAMES_ENABLED (default false).
This commit is contained in:
Miguel Ángel
2026-06-09 18:30:23 -04:00
committed by GitHub
parent 96b8d617d8
commit a468550f82
72 changed files with 4421 additions and 621 deletions
+68 -45
View File
@@ -9,7 +9,7 @@ import { createThreeAdapter } from "./adapters/three";
import { createTypegpuAdapter } from "./adapters/typegpu";
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
import { createWaapiAdapter } from "./adapters/waapi";
import { readElementPlaybackRate, refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { createPickerModule } from "./picker";
import { createRuntimePlayer } from "./player";
@@ -954,33 +954,13 @@ export function initSandboxRuntimeModular(): void {
state.capturedTimeline.totalTime(seekTime, false);
}
// Strip stale CSS offset artifacts from GSAP-targeted elements.
// These leak into the HTML when the CSS offset path fires for a
// GSAP-animated element (stale cache race). On reload, both the
// offset and GSAP transform stack, doubling the visual position.
const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]");
if (staleEls.length > 0 && state.capturedTimeline.getChildren) {
const tweenTargets = new Set<Element>();
try {
for (const child of state.capturedTimeline.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) tweenTargets.add(t);
}
}
} catch {
/* timeline access guard */
}
for (const el of staleEls) {
if (!tweenTargets.has(el)) continue;
const htmlEl = el as HTMLElement;
htmlEl.removeAttribute("data-hf-studio-path-offset");
htmlEl.removeAttribute("data-hf-studio-original-translate");
htmlEl.removeAttribute("data-hf-studio-original-inline-translate");
htmlEl.style.removeProperty("--hf-studio-offset-x");
htmlEl.style.removeProperty("--hf-studio-offset-y");
htmlEl.style.removeProperty("translate");
}
}
// GSAP bakes the CSS `translate` into style.transform on seek.
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
// directly here as well, since the wrapper may not be installed yet
// during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn();
}
if (resolution.diagnostics) {
postRuntimeMessage({
@@ -1000,6 +980,51 @@ export function initSandboxRuntimeModular(): void {
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
},
});
// Stamp data-start / data-duration on GSAP-targeted elements that lack
// them so the Studio timeline can discover individual animated elements.
{
const rootComp = resolveRootCompositionElement();
const rootDuration = boundDuration > 0 ? boundDuration : 0;
const dur = String(rootDuration > 0 ? rootDuration : 1);
const seen = new Set<Element>();
// Stamp GSAP-targeted elements
if (state.capturedTimeline.getChildren) {
try {
for (const child of state.capturedTimeline.getChildren(true)) {
if (typeof child.targets !== "function") continue;
for (const target of child.targets()) {
if (!(target instanceof HTMLElement)) continue;
if (target === rootComp) continue;
if (target.hasAttribute("data-start")) continue;
if (seen.has(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
target.setAttribute("data-duration", dur);
}
}
} catch {
/* timeline access guard */
}
}
// Stamp all ID'd children of the composition root so they appear
// in the timeline even without animations. Enables selecting and
// adding animations from the design panel on a blank canvas.
if (rootComp instanceof HTMLElement) {
for (const el of rootComp.querySelectorAll("[id]")) {
if (!(el instanceof HTMLElement)) continue;
if (el === rootComp) continue;
if (el.hasAttribute("data-start")) continue;
if (seen.has(el)) continue;
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
seen.add(el);
el.setAttribute("data-start", "0");
el.setAttribute("data-duration", dur);
}
}
}
// (Re-)probe all already-bound media elements against the new timeline.
// Clear the cache first so elements probed against a prior timeline get fresh keyframes.
for (const el of metadataBoundMedia) {
@@ -1356,7 +1381,6 @@ export function initSandboxRuntimeModular(): void {
const mediaStart =
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
0;
const playbackRate = readElementPlaybackRate(element);
const hostRemaining =
context.inheritedStart != null &&
context.inheritedDuration != null &&
@@ -1365,7 +1389,7 @@ export function initSandboxRuntimeModular(): void {
: null;
const sourceDuration =
Number.isFinite(element.duration) && element.duration > mediaStart
? Math.max(0, (element.duration - mediaStart) / playbackRate)
? Math.max(0, element.duration - mediaStart)
: null;
if (sourceDuration != null && hostRemaining != null) {
return Math.min(sourceDuration, hostRemaining);
@@ -1758,28 +1782,27 @@ export function initSandboxRuntimeModular(): void {
postState(true);
};
let buildListenerPending = false;
maybePublishRenderReady = () => {
if (!externalCompositionsReady) {
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
window.__renderReady = false;
return;
}
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
if (!buildListenerPending) {
buildListenerPending = true;
const onBuilt = () => {
buildListenerPending = false;
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onBuilt, { once: true });
}
return;
}
publishRenderReadyAfterTimelineBinding();
};
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
// active, composition scripts queue tl.to() calls instead of executing them
// synchronously. Wait for the "hf-timelines-built" event before the first
// binding attempt so the transport clock receives the finished timeline
// duration instead of permanently publishing duration=0.
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
const onTimelinesBuilt = () => {
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
}
maybePublishRenderReady();
// When the bundler inlines compositions, data-composition-src is removed so
@@ -230,7 +230,11 @@ export function patchElementInHtml(
}
break;
case "text-content":
if (op.value != null) htmlEl.textContent = op.value;
if (op.value != null) {
const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl;
textTarget.textContent = op.value;
}
break;
}
}
@@ -254,6 +258,35 @@ export interface SplitElementResult {
newId: string | null;
}
function resolveElementTiming(el: Element): {
start: number;
duration: number;
usesDataEnd: boolean;
} {
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const usesDataEnd = el.hasAttribute("data-end");
const duration = usesDataEnd
? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
: parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
return { start, duration, usesDataEnd };
}
function setElementDuration(
el: Element,
start: number,
duration: number,
usesDataEnd: boolean,
): void {
if (usesDataEnd) {
const endTime = String(Math.round((start + duration) * 1000) / 1000);
el.setAttribute("data-end", endTime);
el.removeAttribute("data-duration");
} else {
el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
el.removeAttribute("data-end");
}
}
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
@@ -264,8 +297,7 @@ export function splitElementInHtml(
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
const { start, duration, usesDataEnd } = resolveElementTiming(el);
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
@@ -277,7 +309,7 @@ export function splitElementInHtml(
clone.setAttribute("id", newId);
clone.removeAttribute("data-hf-id");
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
@@ -287,7 +319,8 @@ export function splitElementInHtml(
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1;
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
@@ -295,7 +328,7 @@ export function splitElementInHtml(
}
// Trim the original element's duration
el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000));
setElementDuration(el, start, firstDuration, usesDataEnd);
// Insert clone after original
if (el.nextSibling) {