mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
feat(studio): design panel, timeline polish, feature flag [6/6] (#1172)
* 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 * 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. * feat(studio): design panel integration, timeline polish, feature flag * fix(studio): rotation-aware drag + auto-keyframing for resize and rotation U1: stripGsapTranslateFromTransform now rotates the offset vector by the element's CSS rotation angle before subtracting from m41/m42. Fixes elements drifting from cursor during drag when rotated. U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the runtime bridge. Resize and rotation handle changes now create keyframes via the same async pipeline as position drag. CSS path guards prevent double-persistence for GSAP-animated elements. * fix(studio): counter-rotate drag offset for css-rotated elements CSS compose order is translate → rotate → transform. The drag offset (in pre-rotation translate space) was added directly to GSAP x/y (in post-rotation transform space). Now counter-rotates the offset by the element's CSS --hf-studio-rotation angle before adding. * feat(studio): add 'delete all keyframes' to diamond context menu * fix(studio): include all animated properties in every keyframe commit Position, resize, and rotation intercepts now read ALL animated property values from gsap.getProperty() at commit time and include them in the keyframe. Prevents other properties from jumping to interpolated values between surrounding keyframes when only one property (e.g., width) was explicitly changed.
This commit is contained in:
@@ -5,6 +5,7 @@ import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
||||
import { DomEditOverlay } from "./editor/DomEditOverlay";
|
||||
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
@@ -101,6 +102,12 @@ export function StudioPreviewArea({
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
handleDomRotationCommit,
|
||||
selectedGsapAnimations,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
} = useDomEditContext();
|
||||
|
||||
return (
|
||||
@@ -121,6 +128,53 @@ export function StudioPreviewArea({
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
||||
onSelectTimelineElement={handleTimelineElementSelect}
|
||||
onDeleteAllKeyframes={(_elId) => {
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (anim) handleGsapRemoveAllKeyframes(anim.id);
|
||||
}}
|
||||
onDeleteKeyframe={(_elId, pct) => {
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (anim) handleGsapRemoveKeyframe(anim.id, pct);
|
||||
}}
|
||||
onChangeKeyframeEase={(_elId, _pct, ease) => {
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (anim) handleGsapUpdateMeta(anim.id, { ease });
|
||||
}}
|
||||
// fallow-ignore-next-line complexity
|
||||
onMoveKeyframe={(_el, oldPct, newPct) => {
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (!anim?.keyframes) return;
|
||||
const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct);
|
||||
if (!kf) return;
|
||||
handleGsapRemoveKeyframe(anim.id, oldPct);
|
||||
for (const [prop, val] of Object.entries(kf.properties)) {
|
||||
handleGsapAddKeyframe(anim.id, newPct, prop, val);
|
||||
}
|
||||
}}
|
||||
onToggleKeyframeAtPlayhead={(el) => {
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
const pct =
|
||||
el.duration > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)),
|
||||
)
|
||||
: 0;
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (anim?.keyframes) {
|
||||
const existing = anim.keyframes.keyframes.find(
|
||||
(k) => Math.abs(k.percentage - pct) <= 1,
|
||||
);
|
||||
if (existing) {
|
||||
handleGsapRemoveKeyframe(anim.id, existing.percentage);
|
||||
} else {
|
||||
handleGsapAddKeyframe(anim.id, pct, "x", 0);
|
||||
}
|
||||
} else {
|
||||
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
|
||||
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
|
||||
}
|
||||
}}
|
||||
onCompIdToSrcChange={setCompIdToSrc}
|
||||
onCompositionLoadingChange={setCompositionLoading}
|
||||
onCompositionChange={(compPath) => {
|
||||
|
||||
@@ -9,13 +9,29 @@ import {
|
||||
METHOD_LABELS,
|
||||
METHOD_TOOLTIPS,
|
||||
PERCENT_PROPS,
|
||||
PROP_CONSTRAINTS,
|
||||
PROP_LABELS,
|
||||
PROP_TOOLTIPS,
|
||||
PROP_UNITS,
|
||||
clampPropertyValue,
|
||||
} from "./gsapAnimationConstants";
|
||||
import { buildTweenSummary } from "./gsapAnimationHelpers";
|
||||
import { EaseCurveSection } from "./EaseCurveSection";
|
||||
const BOOLEAN_PROPS = new Set(["visibility"]);
|
||||
const STRING_PROPS = new Set(["filter", "clipPath"]);
|
||||
|
||||
const FILTER_PRESETS = [
|
||||
{ label: "Blur", value: "blur(4px)" },
|
||||
{ label: "Bright", value: "brightness(1.5)" },
|
||||
{ label: "Gray", value: "grayscale(1)" },
|
||||
{ label: "None", value: "none" },
|
||||
];
|
||||
|
||||
const CLIP_PATH_PRESETS = [
|
||||
{ label: "Circle", value: "circle(50% at 50% 50%)" },
|
||||
{ label: "Inset", value: "inset(10%)" },
|
||||
{ label: "None", value: "none" },
|
||||
];
|
||||
|
||||
function isPercentProp(prop: string): boolean {
|
||||
return PERCENT_PROPS.has(prop);
|
||||
@@ -27,7 +43,11 @@ function displayValue(prop: string, val: number | string): string {
|
||||
}
|
||||
|
||||
function adjustedValue(prop: string, raw: string): string {
|
||||
if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100)));
|
||||
if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
|
||||
const num = Number(raw);
|
||||
if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
|
||||
return String(clampPropertyValue(prop, num));
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -90,6 +110,48 @@ function PropertyRow({
|
||||
);
|
||||
}
|
||||
|
||||
if (STRING_PROPS.has(prop)) {
|
||||
const presets =
|
||||
prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
|
||||
<span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
|
||||
{PROP_LABELS[prop] ?? prop}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={String(val)}
|
||||
className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
|
||||
onBlur={(e) => onCommit(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RemoveButton onClick={onRemove} title={removeTitle} />
|
||||
</div>
|
||||
{presets.length > 0 && (
|
||||
<div className="flex gap-1 pl-1">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => onCommit(p.value)}
|
||||
className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -292,8 +354,10 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
{methodLabel}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
|
||||
{typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "}
|
||||
{typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime}
|
||||
{typeof animation.position === "number"
|
||||
? `${parseFloat(animation.position.toFixed(3))}s`
|
||||
: animation.position}{" "}
|
||||
– {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
|
||||
{easeLabel}
|
||||
@@ -344,7 +408,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
value={
|
||||
typeof animation.position === "string"
|
||||
? animation.position
|
||||
: String(Math.max(0, animation.position))
|
||||
: String(parseFloat(Math.max(0, animation.position).toFixed(3)))
|
||||
}
|
||||
suffix={typeof animation.position === "number" ? "s" : undefined}
|
||||
tooltip="When this effect begins on the timeline"
|
||||
|
||||
@@ -14,7 +14,9 @@ import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
|
||||
import { TextSection, StyleSections } from "./propertyPanelSections";
|
||||
import { GsapAnimationSection } from "./GsapAnimationSection";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
||||
import { usePlayerStore } from "../../player";
|
||||
|
||||
// Re-export helpers that external consumers import from this module
|
||||
export {
|
||||
@@ -65,6 +67,15 @@ interface PropertyPanelProps {
|
||||
onAddGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
onAddKeyframe?: (
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onConvertToKeyframes?: (animationId: string) => void;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -170,6 +181,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onAddGsapFromProperty,
|
||||
onRemoveGsapFromProperty,
|
||||
onAddGsapAnimation,
|
||||
onAddKeyframe,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onSeekToTime,
|
||||
}: PropertyPanelProps) {
|
||||
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
||||
|
||||
@@ -223,6 +238,11 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
|
||||
const parsed = parsePxMetricValue(nextValue);
|
||||
if (parsed == null) return;
|
||||
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
|
||||
onAddKeyframe(gsapAnimId, pct, axis, parsed);
|
||||
return;
|
||||
}
|
||||
const current = readStudioPathOffset(element.element);
|
||||
onSetManualOffset(element, {
|
||||
x: axis === "x" ? parsed : current.x,
|
||||
@@ -256,6 +276,16 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetManualRotation(element, { angle: parsed });
|
||||
};
|
||||
|
||||
// Keyframe navigation state
|
||||
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
|
||||
|
||||
const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
|
||||
const gsapAnimId =
|
||||
gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
|
||||
<div className="border-b border-neutral-800 px-4 py-5">
|
||||
@@ -317,39 +347,118 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
|
||||
<Section title="Layout" icon={<Move size={15} />}>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<MetricField
|
||||
label="X"
|
||||
value={formatPxMetricValue(manualOffset.x)}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualOffset("x", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="Y"
|
||||
value={formatPxMetricValue(manualOffset.y)}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualOffset("y", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="W"
|
||||
value={formatPxMetricValue(resolvedWidth)}
|
||||
disabled={manualSizeEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualSize("width", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="H"
|
||||
value={formatPxMetricValue(resolvedHeight)}
|
||||
disabled={manualSizeEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualSize("height", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="R"
|
||||
value={`${manualRotation.angle}°`}
|
||||
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="X"
|
||||
value={formatPxMetricValue(manualOffset.x)}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualOffset("x", next)}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="x"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="Y"
|
||||
value={formatPxMetricValue(manualOffset.y)}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualOffset("y", next)}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="y"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="W"
|
||||
value={formatPxMetricValue(resolvedWidth)}
|
||||
disabled={manualSizeEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualSize("width", next)}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="width"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="H"
|
||||
value={formatPxMetricValue(resolvedHeight)}
|
||||
disabled={manualSizeEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualSize("height", next)}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="height"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) =>
|
||||
onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="R"
|
||||
value={`${manualRotation.angle}°`}
|
||||
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="rotation"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) =>
|
||||
onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<MetricField
|
||||
|
||||
@@ -27,6 +27,16 @@ export const PROP_LABELS: Record<string, string> = {
|
||||
autoAlpha: "Visibility",
|
||||
visibility: "Visible",
|
||||
scaleX_alias: "Stretch X",
|
||||
filter: "Filter",
|
||||
clipPath: "Clip Path",
|
||||
color: "Color",
|
||||
backgroundColor: "Background",
|
||||
borderColor: "Border Color",
|
||||
borderRadius: "Radius",
|
||||
fontSize: "Font Size",
|
||||
letterSpacing: "Tracking",
|
||||
skewX: "Skew X",
|
||||
skewY: "Skew Y",
|
||||
};
|
||||
|
||||
export const PROP_UNITS: Record<string, string> = {
|
||||
@@ -83,6 +93,11 @@ export const EASE_LABELS: Record<string, string> = {
|
||||
"expo.out": "Very snappy stop",
|
||||
"expo.in": "Very slow start",
|
||||
"expo.inOut": "Dramatic ease",
|
||||
"spring-gentle": "Gentle spring",
|
||||
"spring-bouncy": "Bouncy spring",
|
||||
"spring-stiff": "Stiff spring",
|
||||
"spring-wobbly": "Wobbly spring",
|
||||
"spring-heavy": "Heavy spring",
|
||||
};
|
||||
|
||||
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
|
||||
@@ -123,6 +138,33 @@ export function parseCustomEaseFromString(ease: string): {
|
||||
|
||||
export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
|
||||
|
||||
export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step?: number }> = {
|
||||
opacity: { min: 0, max: 1, step: 0.01 },
|
||||
autoAlpha: { min: 0, max: 1, step: 0.01 },
|
||||
scale: { min: -10, max: 10, step: 0.01 },
|
||||
scaleX: { min: -10, max: 10, step: 0.01 },
|
||||
scaleY: { min: -10, max: 10, step: 0.01 },
|
||||
rotation: { step: 1 },
|
||||
skewX: { min: -90, max: 90, step: 1 },
|
||||
skewY: { min: -90, max: 90, step: 1 },
|
||||
width: { min: 0, step: 1 },
|
||||
height: { min: 0, step: 1 },
|
||||
borderRadius: { min: 0, step: 1 },
|
||||
x: { step: 1 },
|
||||
y: { step: 1 },
|
||||
fontSize: { min: 1, step: 1 },
|
||||
letterSpacing: { step: 0.1 },
|
||||
};
|
||||
|
||||
export function clampPropertyValue(prop: string, value: number): number {
|
||||
const constraint = PROP_CONSTRAINTS[prop];
|
||||
if (!constraint) return value;
|
||||
let clamped = value;
|
||||
if (constraint.min !== undefined) clamped = Math.max(constraint.min, clamped);
|
||||
if (constraint.max !== undefined) clamped = Math.min(constraint.max, clamped);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const;
|
||||
|
||||
export const ADD_METHOD_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -14,7 +14,8 @@ export function buildTweenSummary(animation: GsapAnimation): string {
|
||||
const props = Object.entries(animation.properties);
|
||||
const target = animation.targetSelector;
|
||||
const dur = animation.duration ?? 0;
|
||||
const pos = animation.position;
|
||||
const rawPos = animation.position;
|
||||
const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos;
|
||||
const propDescs = props.map(([p, v]) => {
|
||||
const label = (PROP_LABELS[p] ?? p).toLowerCase();
|
||||
return `${label} to ${formatPropValue(p, v)}`;
|
||||
|
||||
@@ -68,6 +68,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"],
|
||||
true,
|
||||
);
|
||||
|
||||
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
|
||||
false,
|
||||
);
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
|
||||
}
|
||||
|
||||
function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
||||
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
|
||||
const transform = element.style.getPropertyValue("transform");
|
||||
if (!transform || transform === "none") return;
|
||||
const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
|
||||
@@ -233,8 +234,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
||||
if (m.m41 === 0 && m.m42 === 0) return;
|
||||
const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP);
|
||||
const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP);
|
||||
m.m41 -= offsetX;
|
||||
m.m42 -= offsetY;
|
||||
const angle = Math.atan2(m.b, m.a);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
m.m41 -= offsetX * cos - offsetY * sin;
|
||||
m.m42 -= offsetX * sin + offsetY * cos;
|
||||
if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) {
|
||||
element.style.removeProperty("transform");
|
||||
} else {
|
||||
|
||||
@@ -236,9 +236,25 @@ export function createManualOffsetDragMember(input: {
|
||||
const gestureToken = beginStudioManualEditGesture(input.element);
|
||||
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
|
||||
if (!measured.ok) {
|
||||
restoreStudioPathOffset(input.element, initialPathOffset);
|
||||
endStudioManualEditGesture(input.element, gestureToken);
|
||||
return { ok: false, reason: measured.reason, selection: input.selection };
|
||||
// Fallback: when GSAP transforms interfere with probe measurement, use
|
||||
// the preview scale as an approximation. The commit path reads the actual
|
||||
// GSAP position from the iframe runtime, so visual imprecision during
|
||||
// drag is acceptable — the final committed position is always exact.
|
||||
const scaleX = input.rect.editScaleX || 1;
|
||||
const scaleY = input.rect.editScaleY || 1;
|
||||
return {
|
||||
ok: true,
|
||||
member: {
|
||||
key: input.key,
|
||||
selection: input.selection,
|
||||
element: input.element,
|
||||
initialOffset,
|
||||
initialPathOffset,
|
||||
gestureToken,
|
||||
screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY },
|
||||
originRect: input.rect,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -70,6 +70,11 @@ interface NLELayoutProps {
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
||||
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
||||
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
||||
/** Whether the timeline panel is visible (default: true) */
|
||||
@@ -118,6 +123,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
onSelectTimelineElement,
|
||||
onDeleteKeyframe,
|
||||
onDeleteAllKeyframes,
|
||||
onChangeKeyframeEase,
|
||||
onMoveKeyframe,
|
||||
onToggleKeyframeAtPlayhead,
|
||||
onCompIdToSrcChange,
|
||||
timelineVisible,
|
||||
onToggleTimeline,
|
||||
@@ -448,6 +458,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
onResizeElement={onResizeElement}
|
||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||
onSelectElement={onSelectTimelineElement}
|
||||
onDeleteKeyframe={onDeleteKeyframe}
|
||||
onDeleteAllKeyframes={onDeleteAllKeyframes}
|
||||
onChangeKeyframeEase={onChangeKeyframeEase}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead}
|
||||
/>
|
||||
</div>
|
||||
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
||||
|
||||
Reference in New Issue
Block a user