feat(studio): runtime-synced design panel + 3D props + split polish (#1188)

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

* 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

* 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

* ci: trigger regression run

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

* feat(studio): add 'delete all keyframes' to diamond context menu

* ci: trigger regression run

* ci: trigger regression run

* ci: trigger regression run

* fix(studio): overlay jump, delete-all-keyframes, split wiring, reapplyBoxSizes guard

- Fix overlay bounding box jump: reapplyBoxSizes now skips elements whose
  width/height are animated by GSAP (gsapAnimatesProperty check prevents
  studio CSS from overwriting GSAP interpolated values)
- Delete All Keyframes removes entire animation (handleGsapDeleteAnimation)
  with fallback to first animation when no keyframed anim exists
- Wire split clip through App → StudioPreviewArea → NLELayout → Timeline
  (onSplitElement prop, toolbar button, S hotkey, clip context menu)
- Add onContextMenu to TimelineClip for right-click clip context menu

* fix(studio): block split on sub-compositions

Sub-compositions (data-composition-src) cannot be meaningfully split —
the clone would load the same source and fight for the same timeline.
Block with a specific toast message and disable in the context menu.

* fix(studio): no toast when split is unavailable for compositions

* fix(studio): remove defensive toast from split handler — UI gates are sufficient

* fix(studio): hide split button for sub-compositions, use scissors icon

* ci: trigger regression run

* feat(studio): runtime-synced design panel values + 3D transform properties

The Layout section (X, Y, W, H, R) now reads GSAP-interpolated values
from the runtime via gsap.getProperty() at the current seek time. When
an element has GSAP animations, the fields reflect the actual interpolated
position/size/rotation instead of the CSS defaults.

Also adds 3D transform properties to SUPPORTED_PROPS: z, rotationX,
rotationY, rotationZ, perspective, transformOrigin.

* fix(studio): read ALL animated properties from runtime, not just hardcoded 7

* fix(studio): stronger clip selection border + wider keyframe playhead tolerance

- Selected clip: full accent border (was 38% opacity), subtle glow shadow
- Keyframe diamond at playhead: tolerance 0.5% (was 0.05% — too tight at
  high zoom levels, causing diamonds to never highlight)

* fix(studio): sync DOM selection to timeline selectedElementId on cold load

* fix(studio): use Phosphor Scissors icon for split button

* fix(studio): restrict split to media elements only (video, audio, img)

* feat(studio): unified commitAnimatedProperty for all GSAP property edits

Extract useAnimatedPropertyCommit hook that handles the three-case commit
logic: keyframed → add-keyframe, flat → convert + add, no animation →
create + convert + add. Wire Z, Scale, RotX, RotY design panel fields
and 2D Layout fields through this unified pipeline.

Export readAllAnimatedProperties and readGsapProperty from the runtime
bridge so the commit helper can read all animated props for backfill.

* fix(studio): wire all KeyframeNavigation diamonds through commitAnimatedProperty

* chore: remove committed plan files

These design plans were accidentally committed and should not be in
the PR.
This commit is contained in:
Miguel Ángel
2026-06-05 12:11:01 -04:00
committed by GitHub
parent c1699ec98b
commit 1bdb2d4ec0
20 changed files with 517 additions and 128 deletions
@@ -49,6 +49,7 @@ export interface StudioPreviewAreaProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
@@ -67,6 +68,7 @@ export function StudioPreviewArea({
handleTimelineElementMove,
handleTimelineElementResize,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
@@ -107,7 +109,7 @@ export function StudioPreviewArea({
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleGsapDeleteAnimation,
} = useDomEditContext();
return (
@@ -127,10 +129,12 @@ export function StudioPreviewArea({
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onSplitElement={handleTimelineElementSplit}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(_elId) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim) handleGsapRemoveAllKeyframes(anim.id);
const anim =
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
if (anim) handleGsapDeleteAnimation(anim.id);
}}
onDeleteKeyframe={(_elId, pct) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
@@ -91,6 +91,7 @@ export function StudioRightPanel({
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
commitAnimatedProperty,
} = useDomEditContext();
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
@@ -211,6 +212,7 @@ export function StudioRightPanel({
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
previewIframeRef={previewIframeRef}
gsapAnimations={selectedGsapAnimations}
gsapMultipleTimelines={gsapMultipleTimelines}
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
@@ -223,6 +225,7 @@ export function StudioRightPanel({
onAddGsapFromProperty={handleGsapAddFromProperty}
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
onAddGsapAnimation={handleGsapAddAnimation}
onCommitAnimatedProperty={commitAnimatedProperty}
/>
) : motionPanelActive ? (
<MotionPanel
@@ -6,6 +6,7 @@ import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
import { usePlayerStore, type TimelineElement } from "../player";
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
@@ -218,38 +219,35 @@ export function TimelineToolbar({
</button>
</Tooltip>
)}
{onSplitElement && (
<Tooltip label="Split clip at playhead (S)">
<button
type="button"
onClick={() => {
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
if (!selectedElementId) return;
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
if (el && currentTime > el.start && currentTime < el.start + el.duration) {
onSplitElement(el, currentTime);
}
}}
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-neutral-200"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
>
<line x1="8" y1="2" x2="8" y2="14" />
<polyline points="5,5 3,2" />
<polyline points="11,5 13,2" />
<polyline points="5,11 3,14" />
<polyline points="11,11 13,14" />
</svg>
</button>
</Tooltip>
)}
{onSplitElement &&
(() => {
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
const el = selectedElementId
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
: null;
const splittable =
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
if (!splittable) return null;
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
return (
<Tooltip label="Split clip at playhead (S)">
<button
type="button"
disabled={!canSplit}
onClick={() => {
if (canSplit) onSplitElement(el, currentTime);
}}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
canSplit
? "text-neutral-500 hover:text-neutral-200"
: "text-neutral-700 cursor-not-allowed"
}`}
>
<Scissors size={15} />
</button>
</Tooltip>
);
})()}
</div>
<div className="flex items-center gap-1">
<Tooltip label="Fit timeline to width">
@@ -52,6 +52,7 @@ interface PropertyPanelProps {
onImportAssets?: (files: FileList) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
gsapMultipleTimelines?: boolean;
gsapUnsupportedTimelinePattern?: boolean;
@@ -75,6 +76,11 @@ interface PropertyPanelProps {
) => void;
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onConvertToKeyframes?: (animationId: string) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,
property: string,
value: number | string,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
}
@@ -169,6 +175,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onImportAssets,
fontAssets = [],
onImportFonts,
previewIframeRef,
gsapAnimations = [],
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
@@ -184,6 +191,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
onCommitAnimatedProperty,
onSeekToTime,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
@@ -238,6 +246,10 @@ export const PropertyPanel = memo(function PropertyPanel({
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) {
void onCommitAnimatedProperty(element, axis, parsed);
return;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
onAddKeyframe(gsapAnimId, pct, axis, parsed);
@@ -286,6 +298,49 @@ export const PropertyPanel = memo(function PropertyPanel({
const gsapAnimId =
gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
// Read ALL GSAP-interpolated values at the current seek time.
// Discovers animated properties from the animation's keyframes/tween vars.
const gsapRuntimeValues: Record<string, number> | null = (() => {
if (!gsapAnimId || gsapAnimations.length === 0) return null;
const iframe = previewIframeRef?.current;
if (!iframe?.contentWindow) return null;
const selector = element.id ? `#${element.id}` : element.selector;
if (!selector) return null;
try {
const gsap = (
iframe.contentWindow as unknown as {
gsap?: { getProperty: (el: Element, prop: string) => number | string };
}
).gsap;
if (!gsap?.getProperty) return null;
const el = iframe.contentDocument?.querySelector(selector);
if (!el) return null;
const propKeys = new Set<string>();
for (const anim of gsapAnimations) {
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const p of Object.keys(kf.properties)) propKeys.add(p);
}
}
for (const p of Object.keys(anim.properties)) propKeys.add(p);
}
const result: Record<string, number> = {};
for (const prop of propKeys) {
const v = Number(gsap.getProperty(el, prop));
if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
}
return Object.keys(result).length > 0 ? result : null;
} catch {
return null;
}
})();
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
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">
@@ -351,7 +406,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="X"
value={formatPxMetricValue(manualOffset.x)}
value={formatPxMetricValue(displayX)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("x", next)}
@@ -363,7 +418,10 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
/>
@@ -373,7 +431,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="Y"
value={formatPxMetricValue(manualOffset.y)}
value={formatPxMetricValue(displayY)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("y", next)}
@@ -385,7 +443,10 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
/>
@@ -395,7 +456,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="W"
value={formatPxMetricValue(resolvedWidth)}
value={formatPxMetricValue(displayW)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("width", next)}
@@ -407,7 +468,10 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
/>
@@ -417,7 +481,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="H"
value={formatPxMetricValue(resolvedHeight)}
value={formatPxMetricValue(displayH)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("height", next)}
@@ -429,8 +493,9 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) =>
onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight)
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -441,7 +506,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="R"
value={`${manualRotation.angle}°`}
value={`${displayR}°`}
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
/>
</div>
@@ -451,8 +516,9 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) =>
onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle)
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -460,7 +526,80 @@ export const PropertyPanel = memo(function PropertyPanel({
)}
</div>
</div>
{gsapRuntimeValues && (
<div className="mt-3 border-t border-neutral-800/40 pt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
3D Transform
</div>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Z"
value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
scrub
onCommit={(next) => {
const v = parsePxMetricValue(next);
if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", v);
}
}}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
<KeyframeNavigation
property="z"
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={() => {
if (onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
}
}}
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
/>
)}
</div>
<MetricField
label="Scale"
value={String(gsapRuntimeValues.scale ?? 1)}
scrub
onCommit={(next) => {
const v = Number.parseFloat(next);
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "scale", v);
}
}}
/>
<MetricField
label="RotX"
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationX", v);
}
}}
/>
<MetricField
label="RotY"
value={`${gsapRuntimeValues.rotationY ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationY", v);
}
}}
/>
</div>
</div>
)}
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
Stacking
</div>
<MetricField
label="Z-index"
value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}
@@ -516,8 +516,58 @@ function reapplyPathOffsets(doc: Document): void {
}
}
function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
const win = el.ownerDocument.defaultView as
| (Window & {
__timelines?: Record<
string,
{
getChildren?: (
deep: boolean,
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
}
>;
})
| null;
if (!win?.__timelines) return false;
const propSet = new Set(props);
for (const tl of Object.values(win.__timelines)) {
if (!tl?.getChildren) continue;
try {
for (const child of tl.getChildren(true)) {
if (!child.targets || !child.vars) continue;
let targetsEl = false;
for (const t of child.targets()) {
if (t === el || (el.id && t.id === el.id)) {
targetsEl = true;
break;
}
}
if (!targetsEl) continue;
const vars = child.vars;
for (const p of propSet) {
if (p in vars) return true;
}
if (vars.keyframes && typeof vars.keyframes === "object") {
for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
if (kfVal && typeof kfVal === "object") {
for (const p of propSet) {
if (p in (kfVal as Record<string, unknown>)) return true;
}
}
}
}
}
} catch {
/* */
}
}
return false;
}
function reapplyBoxSizes(doc: Document): void {
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
if (gsapAnimatesProperty(el, "width", "height")) continue;
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
@@ -69,6 +69,7 @@ interface NLELayoutProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onSelectTimelineElement?: (element: TimelineElement | null) => void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
@@ -122,6 +123,7 @@ export const NLELayout = memo(function NLELayout({
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onSelectTimelineElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
@@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({
onMoveElement={onMoveElement}
onResizeElement={onResizeElement}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={onSplitElement}
onSelectElement={onSelectTimelineElement}
onDeleteKeyframe={onDeleteKeyframe}
onDeleteAllKeyframes={onDeleteAllKeyframes}