mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
feat(studio): keyframe hooks wiring — session, cache, toolbar [5/6] (#1171)
* feat(core): GSAP keyframe parsing, mutations, and API routes * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * fix(producer): use video stream duration for PSNR checkpoint range The regression harness used container duration (format.duration) to compute PSNR checkpoints. Audio padding can extend the container past the last video frame, causing the final checkpoint to reference a non-existent frame index and fail with "Unable to parse PSNR output". Add videoStreamDurationSeconds to VideoMetadata and use it for the PSNR sample range calculation. * test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines Baselines regenerated inside Dockerfile.test on the devbox to match the current runtime init.ts changes. Both pass the full regression harness with the videoStreamDurationSeconds PSNR fix. * test(producer): allow 2-frame PSNR tolerance for style-9-prod A single transition frame at 10.742s renders with marginal PSNR (26.6 dB vs 30 threshold) on CI runners but passes on the devbox Docker image. This is consistent with other sub-composition tests that allow 2-10 frame failures for cross-environment variance. * feat(studio): GSAP runtime bridge + optimistic update pattern * feat(studio): keyframe diamonds, navigation controls, context menu * feat(studio): keyframe hooks wiring — session, commits, cache, toolbar toggle
This commit is contained in:
@@ -4,24 +4,214 @@ import {
|
||||
} from "../player/components/timelineZoom";
|
||||
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
||||
import { Tooltip } from "./ui";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
|
||||
function interpolateKeyframeProperties(
|
||||
keyframes: GsapPercentageKeyframe[],
|
||||
pct: number,
|
||||
): Record<string, number> {
|
||||
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
||||
const allProps = new Set<string>();
|
||||
for (const kf of sorted) {
|
||||
for (const p of Object.keys(kf.properties)) {
|
||||
if (typeof kf.properties[p] === "number") allProps.add(p);
|
||||
}
|
||||
}
|
||||
const result: Record<string, number> = {};
|
||||
for (const prop of allProps) {
|
||||
let prev: { pct: number; val: number } | null = null;
|
||||
let next: { pct: number; val: number } | null = null;
|
||||
for (const kf of sorted) {
|
||||
const v = kf.properties[prop];
|
||||
if (typeof v !== "number") continue;
|
||||
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
|
||||
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
|
||||
}
|
||||
if (prev && next && prev.pct !== next.pct) {
|
||||
const t = (pct - prev.pct) / (next.pct - prev.pct);
|
||||
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
|
||||
} else if (prev) {
|
||||
result[prop] = Math.round(prev.val);
|
||||
} else if (next) {
|
||||
result[prop] = Math.round(next.val);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readRuntimeKeyframeValues(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
sel: DomEditSelection,
|
||||
keyframes: GsapPercentageKeyframe[],
|
||||
): Record<string, number> {
|
||||
if (!iframe?.contentWindow) return {};
|
||||
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
|
||||
try {
|
||||
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!gsap?.getProperty) return {};
|
||||
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
||||
if (!selector) return {};
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const element = doc?.querySelector(selector);
|
||||
if (!element) return {};
|
||||
const allProps = new Set<string>();
|
||||
for (const kf of keyframes) {
|
||||
for (const p of Object.keys(kf.properties)) {
|
||||
if (typeof kf.properties[p] === "number") allProps.add(p);
|
||||
}
|
||||
}
|
||||
const result: Record<string, number> = {};
|
||||
for (const prop of allProps) {
|
||||
const val = Number(gsap.getProperty(element, prop));
|
||||
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface DomEditSessionSlice {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
||||
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
|
||||
handleGsapConvertToKeyframes: (animId: string) => void;
|
||||
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
toggleTimelineVisibility: () => void;
|
||||
domEditSession?: DomEditSessionSlice;
|
||||
}
|
||||
|
||||
export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) {
|
||||
// fallow-ignore-next-line complexity
|
||||
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
if (!session) return { state: "none" as const, onToggle: undefined };
|
||||
|
||||
const sel = session.domEditSelection;
|
||||
const anims = session.selectedGsapAnimations;
|
||||
const kfAnim = anims.find((a) => a.keyframes);
|
||||
const flatAnim = anims.find((a) => !a.keyframes);
|
||||
|
||||
let state: "active" | "inactive" | "none" = "none";
|
||||
if (kfAnim?.keyframes && sel) {
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
const pct =
|
||||
elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
||||
? "active"
|
||||
: "inactive";
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const onToggle = sel
|
||||
? () => {
|
||||
const t = usePlayerStore.getState().currentTime;
|
||||
if (kfAnim?.keyframes) {
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
const pct =
|
||||
elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
const existing = kfAnim.keyframes.keyframes.find(
|
||||
(k) => Math.abs(k.percentage - pct) <= 1,
|
||||
);
|
||||
if (existing) {
|
||||
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
||||
} else {
|
||||
const runtimeValues = readRuntimeKeyframeValues(
|
||||
session.previewIframeRef?.current ?? null,
|
||||
sel,
|
||||
kfAnim.keyframes.keyframes,
|
||||
);
|
||||
const values =
|
||||
Object.keys(runtimeValues).length > 0
|
||||
? runtimeValues
|
||||
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
|
||||
for (const [prop, val] of Object.entries(values)) {
|
||||
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
|
||||
}
|
||||
}
|
||||
} else if (flatAnim) {
|
||||
session.handleGsapConvertToKeyframes(flatAnim.id);
|
||||
} else {
|
||||
session.handleGsapAddAnimation("to");
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { state, onToggle };
|
||||
}
|
||||
|
||||
export function TimelineToolbar({
|
||||
toggleTimelineVisibility,
|
||||
domEditSession,
|
||||
}: TimelineToolbarProps) {
|
||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
||||
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
||||
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
||||
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||
Timeline
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||
Timeline
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
||||
<Tooltip
|
||||
label={
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead"
|
||||
: keyframeState === "inactive"
|
||||
? "Add keyframe at playhead"
|
||||
: "Enable keyframes"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
keyframeState === "active"
|
||||
? "text-studio-accent"
|
||||
: keyframeState === "inactive"
|
||||
? "text-neutral-400 hover:text-studio-accent"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
||||
{keyframeState === "active" ? (
|
||||
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
||||
) : (
|
||||
<path
|
||||
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip label="Fit timeline to width">
|
||||
|
||||
Reference in New Issue
Block a user