mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user