Commit Graph
252 Commits
Author SHA1 Message Date
Miguel Angel Simon Sierra cbfb6ba943 fix(studio): crop tool stands down for clips it cannot edit
The always-on crop lifted EVERY selected element's clip-path and restored
only what it could parse as a px inset — selecting an element with a
circle/polygon/percentage clip visually un-clipped it, and deselecting
deleted the authored clip outright.

readElementCropInsets is now tri-state (zeros = no clip, null = a clip
the tool can't represent): uneditable clips get no lift and no handles,
and the lift restores the pre-lift inline value verbatim unless a crop
gesture actually committed.
2026-07-11 04:07:57 -04:00
Miguel Angel Simon Sierra 3525c7ff52 fix(studio): correct gesture commits for scaled and graded elements
- resize on a scale-driven element commits per-axis scale (scaleX/scaleY
  for non-uniform drags) with keyframe normalization to the longhands, and
  clears the width/height draft so size can't double-apply; the intercept
  moves to gsapResizeIntercept.ts
- the drop frame applies the corrected position synchronously in the same
  microtask chain as the soft reload (no network-window jump), and the
  draft pins the anchor through accumulated moves on scaled elements
- gesture size/position math divides by the element's own content scale
- convert-to-keyframes resolves current values through the property-group
  filter for ALL capture passes (opacity/rotationX from unrelated tweens
  no longer leak into a rotation commit), and a grading-hidden source's
  opacity is read from its canvas, not the inline hide
- canvas pointer-down confirms the hover target with a synchronous
  hit-test before starting a marquee (stale-hover race lost selections)
2026-07-11 04:07:06 -04:00
Miguel Ángel dff27cb1df Merge pull request #2111 from heygen-com/feat/timeline-multiselect
feat(studio): timeline multi-select (marquee) + relative group time editing
2026-07-09 17:38:37 -04:00
Miguel Ángel a8f86e653d Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder
feat(studio): lane-model timeline — vertical drag restacks via z-index
2026-07-09 17:37:46 -04:00
Miguel Angel Simon Sierra 36413da7f6 feat(studio): resize selected timeline clips together
Apply handle deltas through a group resize session that snapshots selected members.

Clamp one shared delta against zero start and min duration before preview and commit.

Playback-start changes are calculated per member with the existing trim formula.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 5d27af30b2 feat(studio): move selected timeline clips together
Fan selected body drags through a group move session that snapshots all selected members.

The grabbed clip snap result is applied first, then one shared delta is clamped.

Live preview patches every moved member and release persists once through batch timing.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 1d858f004d feat(studio): highlight timeline selection sets
Render selected styling from selectedElementIds in the timeline.

Sync the set into preview group selection boxes without collapsing the anchor.
2026-07-09 16:54:34 -04:00
James 8de80bf369 feat(sdk,studio): editable template sub-compositions + promote sub-comp element properties 2026-07-09 13:31:04 -07:00
James 7c144ecc30 feat(studio): promote to variable from the design panel 2026-07-09 13:31:03 -07:00
JamesandClaude Fable 5 267b289bb8 feat(studio): bind selected element properties to variables
Ninth PR of the template-variables stack: the promote-a-property gesture.
Select an element on the canvas/timeline, open the Variables tab, and the
panel offers per-property bind actions.

- "Bind selected" card in the Variables panel, built from the selection:
  image/media source (img/video/audio), text, text color, background, and
  font. Each action declares a variable whose default is the element's
  CURRENT value (promoting never changes the render — computed rgb colors
  convert to hex, the first computed font family becomes the font default)
  and writes the declarative binding the runtime resolves: data-var-src /
  data-var-text attributes or `<prop>: var(--id)` styles. Declare + bind
  run as one batched schema edit (one undo step); binding to an
  already-declared id skips the declare and just binds.
- guarded to selections from the composition the session models — a
  selection in another source file never writes bindings into this one.
- core: extract readVariablesForElement into runtime/variableScope.ts,
  shared by color grading and the declarative bindings (was duplicated).
- fix(studio-server): buildSubCompositionHtml's extractElementAttrs
  rebuilt html/body attributes without HTML-escaping values, shredding
  quote-bearing attributes — data-composition-variables (a JSON array)
  came out as mangled bogus attributes, so getVariables() silently
  returned {} on every /preview/comp/* page (no declared defaults, no
  runtime bindings). Pre-existing bug surfaced by live-testing this
  feature; regression test added.

Verified end-to-end in a live session: select headline → Bind text color
→ declaration + var(--headline-color) written to disk → override in the
panel → runtime applies the custom prop and the element renders the
override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:31:03 -07:00
James bc0e0b314b fix(studio): code-review and live-test fixes for the variables stack 2026-07-09 13:31:03 -07:00
JamesandClaude Fable 5 010d49327e feat(studio): render with preview variables + template handoff + docs
Sixth PR of the template-variables Studio stack — closing the loop from
preview to render to developer handoff.

- renders started from the Renders tab now carry the active preview
  variable overrides (StartRenderOptions.variables → POST /render →
  RenderConfig.variables), so "render" produces exactly what the user is
  previewing.
- Variables panel "Use this template" footer: copy the effective values
  (defaults merged with overrides) as JSON, or as a ready-to-run
  `npx hyperframes render <comp> --variables '<json>'` command.
- gitignore: negate the renders/ output rule for the tracked
  src/components/renders/ source dir — without it, pre-commit's format
  re-stage (`git add {staged_files}`) hard-fails on any change to those
  files.
- docs: the Studio panel docs/concepts/variables.mdx described was
  aspirational — replace with the real Variables-in-Studio section
  (declare/edit, render-truthful preview, render-with-values, handoff,
  usage badges); document the new SDK variable APIs in
  docs/sdk/reference/composition.mdx (declaration ops, read APIs,
  setPreviewVariables).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:31:03 -07:00
James b34ad85165 feat(studio): variables inspector panel with live preview values 2026-07-09 13:31:03 -07:00
Miguel Angel Simon Sierra e5ed512529 feat(studio): timeline track = stacking layer (NLE-style layering)
Re-architect the timeline row model from data-track-index rows to stacking
layers. Rows represent stacking layers per context: explicit-z clips merge onto
one track when they share a z and don't overlap in time; auto-z clips stay one
row each (DOM order); audio is pulled into its own bottom lanes. Rows keyed by a
stable layer id, not data-track-index.

Vertical drag always writes z-index, never track: drop onto a layer joins it
(same z), between layers interpolates a new z, past the ends creates a new
front/back layer. data-track-index is never rewritten; #958 holds. Adds
hasExplicitZIndex capture (computed z != auto).

L1: hasExplicitZIndex on the element model
L2: buildStackingTimelineLayers (layer-based rows)
L3: layer-aware vertical drag (join / interpolate / new-extreme)
2026-07-08 23:46:25 -04:00
Miguel Angel Simon Sierra dae26e72b2 refactor(studio): extract shared layerOrdering module from LayersPanel 2026-07-08 23:46:24 -04:00
Miguel Ángel 0ac000181e feat(studio): storyboard empty state with agent copy-prompt and skeleton preview (#2091)
The storyboard view's empty state was a single "no storyboard yet" line. Add a
copy-to-clipboard prompt box (a ready-to-paste handoff prompt carrying the
canonical STORYBOARD.md frontmatter + per-frame format) so users can hand it to
their coding agent, plus a faded skeleton of the contact-sheet grid so landing
on an empty board previews what a filled one looks like instead of a dead end.
2026-07-08 22:32:40 -04:00
Miguel Ángel 623f91d6a1 feat(studio): always-on crop with reposition handle, drop crop mode (#2090)
Crop is now part of the element selection instead of a separate mode. Selecting
a croppable element shows edge handles just outside each side and, once cropped,
the full content with the cropped-away area dimmed plus a center reposition
handle to pan the crop window. Dragging the body moves the element, edge handles
crop, the center handle pans; corners stay free for the resize handle. Removes
the crop-mode toggle (toolbar + property-panel buttons), the cropMode/
cropAvailable player-store state, and the double-click-to-crop gesture. The
clip-path inset model is unchanged.
2026-07-08 22:24:26 -04:00
Miguel Ángel 574da2b215 fix(studio): off-canvas indicators track live layout instead of going stale (#2018)
The dashed indicators for elements outside the canvas stayed pinned at an element's
old position after a move or a seek: they were recomputed by an effect keyed on
compRect/activeCompositionPath, neither of which changes on an in-place soft-reload
edit or a playhead seek.

They now recompute via a MutationObserver on the preview document (coalesced to one
recompute per frame), so they follow the element live. Crop-hugging is preserved.
Adds a regression test that mutates an element in place and asserts the indicator moves.
2026-07-07 04:39:46 -04:00
Miguel Ángel 037266e72b feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling

WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload

Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
2026-07-07 04:26:56 -04:00
ukimsanov 8f022d06ec fix(studio): remove selection overlay fill 2026-07-06 21:02:36 -07:00
Miguel Angel Simon Sierra 676f461f6a feat(studio): non-destructive crop + cross-project asset view 2026-07-06 23:40:32 -04:00
Vance Ingalls 89e36da13d fix(studio): shell UX — data-loss guards, error surfacing, dialog contracts, toasts (#1964) 2026-07-06 16:56:06 -07:00
Vance Ingalls 241f9d683e feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX (#1963) 2026-07-06 16:43:48 -07:00
Vance Ingalls cd1adcb581 feat(studio): harden ui primitives — focus rings, keyboard tooltips, dialog hook (#1962)
## Summary

Base of the studio UX-review stack (148 findings audited across the studio; 13 critical). This PR hardens the shared `components/ui` primitives that every later PR in the stack builds on.

## Changes

- **Button / IconButton**: visible `focus-visible` outline (studio accent); `disabled:pointer-events-none` removed (replaced with `disabled:cursor-not-allowed`, hover/active gated behind `enabled:`) so disabled buttons can host explain-why tooltips.
- **Tooltip**: keyboard support (`onFocus`/`onBlur` triggers), `role="tooltip"`, Escape-to-hide, viewport flip (top↔bottom) + horizontal clamping. API unchanged — all ~28 call sites unaffected.
- **HyperframesLoader**: `role="status"` on the loader; determinate track is a real `role="progressbar"` with `aria-valuenow/min/max` (was `aria-hidden`).
- **VideoFrameThumbnail**: error event resolves to a static fallback-label tile instead of an infinite shimmer; `motion-reduce` guard.
- **NEW `useDialogBehavior`**: shared modal contract — document-level Escape, Tab focus trap, focus-first-on-open, focus-restore-on-close, `canClose()` veto for dirty-draft guards. Adopted by every modal later in the stack.
- **NEW `SearchInput`**: shared search primitive with required `aria-label`, panel-input token style (kills the two-divergent-search-styles inconsistency in the sidebar).
- **studio.css**: `hf-toast-in/out` + `hf-backdrop-in` keyframes with `prefers-reduced-motion` guards (the previous `animate-in fade-in` classes were dead — no tailwindcss-animate plugin exists).

## Verification

- oxlint 0 errors, oxfmt clean, `tsc --noEmit` clean at stack top
- Full studio suite at stack top: 1189 tests pass

## Stack

PR 1/7 of the studio UX-review fixes. Merges bottom-up; the stack top is fully green (tsc + 1189 tests). Some shared-file edits span PRs, so intermediate branches may not typecheck in isolation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-06 16:07:44 -07:00
ukimsanov 04f96da169 fix(studio): share safe media path cleanup 2026-07-06 14:35:41 -07:00
ukimsanov e0090efbf6 fix(studio): avoid regex in media asset path cleanup 2026-07-06 14:15:30 -07:00
ukimsanov 30a944de34 fix(studio): split color grading inspector files 2026-07-06 13:56:37 -07:00
ukimsanov 139cf568b4 feat(studio): add color grading inspector 2026-07-06 13:40:20 -07:00
ukimsanov 7c14438496 feat(core): add media color grading contract 2026-07-06 02:36:17 -07:00
Miguel Ángel 08c0a03510 test(studio): agent-browser e2e smoke for the design panel (#1912)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.

* fix(studio): surface persist failures with toast and guarded revert

- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted

* fix(studio): revert data-attribute and html-attribute commits on persist failure

commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.

* fix(studio): close coupled persist-hook review findings

Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.

* test(studio): cover persist-failure hook behavior

Regression tests for the persist failure paths: unresolvable targets,
no-op warns, rejected requests, revert races, structural-edit refusal,
and read/write failure toasts.

* test(studio): cover attribute-commit revert on persist failure

Regression tests for the data-attribute and html-attribute revert paths
added in #1910: unresolvable target, rejected request, success (no revert),
and a stale-failure-vs-newer-success race guarded by the per-attribute
version counter.

* test(studio): cover the patch-rejection and text-commit revert fixes

Adds the two persist-hook cases R2 flagged as untested: the
!patchResponse.ok HTTP-error path (previously only exercised via a
network-throw, which bypassed this branch) and handleDomTextCommit's
server-failure path. Also strengthens the prepareContent-write-failure
test to assert the already-persisted base patch is recorded, not
reverted, matching the coupled persist-hook fix.

* test(studio): agent-browser e2e smoke for the design panel

Standalone script driving selection plus one input per panel section
against a running preview, asserting disk persistence and reload survival.

* fix(studio): close smoke-test quality nits, add fault-injection coverage

Closes the R2/R3 findings on the design-panel e2e smoke script:

- Section lookup no longer matches h3 display text plus a manual tree
  walk (breaks on wording tweaks). Section now carries a stable
  data-panel-section attribute; the script queries by it directly.
- Fields are located by their sibling label (or, where none exists,
  by being the section's only input of that type) instead of by
  guessing the fixture's current value ahead of time.
- Fixed sleep(1400/2000/6000) waits replaced with polling on the
  actual condition (selection registered, section rendered, patch
  round-tripped, app booted). This surfaced a real bug while
  verifying: computing click coordinates right after a commit reused
  a stale preview-frame position from before the property panel's
  reflow, silently clicking the wrong spot — now waits for the
  frame's rect to stabilize first. Also found and fixed a disk-write
  race on the first commit of a run (patch fetch resolves before the
  server's file write lands).
- FAIL now dumps window.__patchLog for diagnosability.
- Added a fault-injection cell: the server rejects a patch and the
  panel must toast the rejection without persisting it or clobbering
  the prior committed value.

Verified by actually running the script with agent-browser against a
live preview (previously never exercised this way) — all 14 checks
pass across repeated clean runs.
2026-07-03 18:50:23 -07:00
Miguel Ángel 22942280b6 fix(studio): per-child patch op builders and persist-seam harness (#1909)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
2026-07-03 18:14:17 -07:00
Miguel Ángel 02e9d6142d fix(studio): make canvas selection hit intended elements (#1907)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing
2026-07-03 17:41:01 -07:00
Miguel Angel Simon Sierra ae50327bdb style(studio): make the auto-keyframe toggle icon a diamond with a record dot
Reuses the same diamond outline as the Add-keyframe icon next to it, with a
small dot inside carrying the on/off state (filled = auto-recording, hollow
= manual edits won't be keyframed) — pairs the two icons visually instead of
an unrelated circle/slash glyph.
2026-07-01 19:41:10 -07:00
Miguel Angel Simon Sierra 636d5dd6c5 feat(studio): add a global toggle to stop manual edits from auto-recording keyframes
Adds a control-bar toggle (next to the Add Keyframe diamond) that, when off,
makes a manual drag/resize/rotate/panel edit on an already-keyframed element
shift the whole tween by the edit's delta instead of inserting or updating a
keyframe at the playhead. The animation's shape is preserved, just moved.

Wired into every path that can auto-record a keyframe:
- canvas drag-to-move (tryGsapDragIntercept, reuses the existing Alt-drag
  "shift whole path" behavior)
- canvas resize/rotate (tryGsapResizeIntercept, tryGsapRotationIntercept)
- design-panel property edits (useAnimatedPropertyCommit)
- motion-path keyframe-node dragging (MotionPathOverlay), the path a plain
  click-drag on a keyframed element's canvas shape actually takes, since the
  element renders exactly at its current keyframe's position

The shared shift helper reuses synthesizeFlatTweenKeyframes for materializing
a flat tween instead of hand-rolling it, and lives in its own file
(gsapWholePropertyOffsetCommit.ts) to keep gsapDragCommit.ts under the
600-line cap, mirroring the existing gsapDragPositionCommit.ts split.

Fixes #1808
2026-07-01 19:26:03 -07:00
Miguel Ángel 9b311588be fix(studio): resolve recent timeline regressions 2026-07-01 03:18:45 +00:00
Vance IngallsandClaude Opus 4.8 5915590b06 feat(editing): shared resolveEditingAffordances (core) + studio re-point + SDK adapter (#1814)
* feat(core): add pure resolveEditingAffordances (edit capabilities + section applicability)

* fix(core): replace prohibited as-cast and !-assertions in isIdentityTransform

* refactor(studio): consume core resolveEditingAffordances; drop duplicated capability + section logic

- affordances.ts: add matrix3d identity-transform branch (was missing, caused test regression)
- domEditingLayers: add domEditSelectionToFacts mapper; resolveDomEditCapabilities is now a thin
  wrapper over core (kept for backward-compat — tests + barrel import it); isTextEditableSelection
  delegates to core sections.text; drop parsePx + isIdentityTransform imports (now in core)
- PropertyPanel: import resolveEditingAffordances + domEditSelectionToFacts; compute sections once;
  replace isMediaElement/isColorGradingCapableElement/timing inline check with sections.*
- propertyPanelMediaSection: delete isMediaElement (no remaining callers)
- propertyPanelColorGradingSection: delete isColorGradingCapableElement (no remaining callers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sdk): add browser-only resolveElementAffordances adapter over core

* fix(sdk): add position to inlineStyles, replace ! assertion with guard in test

- Add missing 'position' key to inlineStyles in affordances.ts to match computedStyles
- Replace non-null assertion (doc.defaultView!) with proper null guard in test

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(editing): resolve code-review findings on affordances feature

Max-effort review (8 verified findings) fixes:

Correctness regressions (studio behavior):
- SVG selection crash: dropped `classNames` from EditableElementFacts
  entirely (it was never read by the resolver), which removes the
  `.className.split()` calls that throw on SVGElement (className is an
  SVGAnimatedString, not a string). Masked in tests by happy-dom.
- Timing panel hidden for GSAP-only layers: domEditSelectionToFacts now
  takes animationCount from the caller; PropertyPanel feeds the live
  gsapAnimations prop (selection.gsapAnimations is never populated).

Cleanups:
- Removed dead inline `position` key from SDK adapter (core reads position
  only from computedStyles).
- Added sections-only `resolveEditingSections` export; PropertyPanel uses it
  so panel re-renders no longer re-run the capability geometry parse.
- Declared happy-dom in packages/sdk devDependencies (was root-hoist only).
- Deduped the two capability fact-construction sites behind a shared
  capabilityFacts() helper.
- parsePx now has a single source of truth in core; studio domEditingDom
  re-exports it so the copies can't drift. isIdentityTransform is now
  core-internal (studio's only consumer moved to core in the prior task).

bun.lock also reconciles stale 0.7.17->0.7.21 package versions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:46:32 -07:00
James RussoandClaude Opus 4.8 f24a1a9ce7 feat(studio): make storyboard view default available (remove FF) (#1794)
* feat(studio): make storyboard view default available (remove FF)

Removes STUDIO_STORYBOARD_ENABLED. The storyboard view-mode toggle was
gated behind a default-off feature flag (VITE_STUDIO_ENABLE_STORYBOARD)
since #1529. With the storyboard experience now ready for broad
exposure, drop the gating and make the toggle available unconditionally.

Changes:
- packages/studio/src/components/editor/manualEditingAvailability.ts:
  delete the STUDIO_STORYBOARD_ENABLED constant.
- packages/studio/src/App.tsx: drop the import + FF arg to
  useViewModeState(). Hook is now called argument-free.
- packages/studio/src/components/StudioHeader.tsx: drop the import + the
  conditional-render guard on <ViewModeToggle />. The toggle always
  renders in StudioHeader's center slot.
- packages/studio/src/contexts/ViewModeContext.tsx: remove the enabled:
  boolean parameter from useViewModeState() and simplify.
- packages/studio/fixtures/storyboard-sample/README.md: drop the
  VITE_STUDIO_ENABLE_STORYBOARD=1 prefix from the preview command.

The VITE_STUDIO_ENABLE_STORYBOARD / VITE_STUDIO_STORYBOARD_ENABLED env
vars become no-ops after this change.

Co-Authored-By: Jerrai <noreply@anthropic.com>

* docs(skills): drop stale VITE_STUDIO_ENABLE_STORYBOARD reference

The Storyboard view is now available by default (the FF removed in this PR);
storyboard-format.md no longer points at the dead env var, and skills-manifest
is regenerated for the hyperframes-core hash. Closes the Via/Magi review nit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jerrai <noreply@anthropic.com>
2026-06-29 22:32:52 -07:00
Miguel Ángel b403c54ae7 feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782) (#1784)
* feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes #1782)

Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture
to retime an existing keyframe while preserving its value and easing (delete+re-add
bakes computed values and drops the explicit ease). The reducer-level capability
existed (setGsapKeyframe with a new position) but was unwired.

Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and
recast, in parity) that re-keys a keyframe to a new percentage, carrying its
properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to
Playhead' entry on the keyframe context menu through both hosts (canvas
MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the
playhead's tween-relative percentage.

Tests: parser correctness + recast/acorn parity (value+ease preserved, collision
overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt
clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow
health gate (parity-twin + wiring-layer duplication; extracted helper).

* feat(studio): restore drag-to-retime on timeline keyframes

Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic
move-keyframe foundation so it's reliable. #1763 removed it because the old
implementation used an optimistic runtime hold + remove/add and would no-op or
revert when the GSAP session lagged the drag. This version:

- previews visual-only (the dragged diamond follows the pointer; nothing touches
  the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves
  value + ease) — no optimistic hold, no lag race.
- pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion,
  clamp [0,100], no-op when drop==origin (unit-tested).
- wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline →
  TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the
  dragged keyframe's animation via resolveKeyframeTarget.

tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate
(same parity/wiring duplication as the rest of the branch).

* feat(studio): complete keyframe-drag UX — neighbor clamp + boundary resize

Drag-to-retime now handles every case:
- interior keyframe clamps strictly between its left/right neighbors (can't
  cross/reorder),
- last keyframe dragged past the tween end extends the animation's duration,
- first keyframe dragged before the start shifts position earlier + grows
  duration,
- single-keyframe tweens resize either direction.

Boundary extends remap the other keyframes to preserve their absolute times
(value + per-keyframe ease copied through) via the atomic replace-with-keyframes
mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits
on drop — no optimistic runtime hold.

Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) +
keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea
resolves the tween window + clip timing and dispatches move vs resize.

tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass
(22 new helper tests). Flat keyframe-less tweens still move within window;
boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate.

* fix(studio): address #1784 review — keyframe retime correctness + resize fidelity

Round 2 from Via + Rames:
- (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache
  lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on
  any tween shorter than its clip (Move to Playhead + the inherited Delete silently
  no-op'd). Menu now passes clip-%.
- boundary resize preserved author intent: new record-preserving parser op
  resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf
  ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes
  which dropped them.
- resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent
  (retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget.
- moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal
  (<0.05), collision only vs a different keyframe.
- soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate
  resolves stale position-encoded ids).

Tests: parser parity (small move + resize round-trip fidelity), studio-server
resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths.
735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed
fallow gate (branch-wide parity/wiring duplication).
2026-06-29 14:43:07 -07:00
Miguel Ángel 0a9555a0f7 fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery (#1781)
* feat(player,studio): favicon-blade play icon with pause<->play morph

Replace the play triangle with the right-hand blade from the HyperFrames favicon
and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one
path's d between the blade and two pause bars (gsap added as a studio dep). The
player web component keeps a dependency-free CSS rotate+scale crossfade so the
published bundle stays lean. Both honor prefers-reduced-motion.

* fix(cli): discover local-studio (Vite) preview over IPv6 loopback

The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but
the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so
`preview --selection/--context` reported preview-not-running against a local-studio
preview (e.g. inside the monorepo / bun run dev). Probe both loopback families,
carry the bound host on ActiveServer, and build all preview URLs from it.

Adds an IPv6-only discovery regression test.

* fix(studio): wire the Add-keyframe (K) shortcut

The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was
never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so
K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar
(enabled when a keyframeable element is selected) wired to the toolbar's add action;
register it in the capture phase and stopImmediatePropagation only for keys it
actually handles, so K adds a keyframe in that context while JKL playback keeps
working everywhere else.

* fix(studio): clear orphaned GSAP transforms on soft reload

A manually-dragged element is positioned via gsap.set, which writes an inline
transform. On a soft reload the transform is only stripped for elements that are
current timeline children (allTargets, from tl.getChildren().targets()). An
element positioned by a standalone gsap.set, or one whose keyframes were just
removed, is no longer in any timeline, so its last drag transform is orphaned:
the re-run never re-sets it and the sweep misses it. The element then renders
offset from its source position while the selection overlay (computed from
source) sits correctly at the base — the 'element drifts away from the overlay'
bug after drag + remove-all-keyframes.

Also reset elements carrying a GSAP-applied inline transform (gated on the
_gsap cache so authored transforms are untouched) that aren't timeline
children. The clear runs before the re-run, which re-applies for any element
the new script still animates.

* fix(studio-server): bust thumbnail cache on composition edits

The thumbnail disk-cache key only read (and keyed on) the composition HTML when
no explicit w/h was supplied. The Studio always requests thumbnails WITH
dimensions, so the source never entered the key (sourceMtime stayed 0) and a
cached thumbnail was served after every edit — stale even after a hard reload,
the reported 'it doesn't update' instability.

Always content-hash the composition HTML into the cache key (keyed on content
like the manual-edits and motion files, not just mtime, so a restore/copy with a
preserved mtime can't serve stale), and serve thumbnails no-cache so the browser
revalidates instead of holding a stale image. Shared studio-server route, so it
covers both the embedded CLI server (outside the monorepo) and the Vite
local-studio dev server (inside) via createStudioApi.

* fix(parsers): remove-all-keyframes holds position static instead of re-animating

removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that
KEPT the original duration, so removing all keyframes re-animated the element
from its base toward the last keyframe value. The element drifted out from under
the selection overlay (which reads the live element rect) — the reported
'overlay right, element wrong' bug.

Collapse to a static hold instead: duration 0 + immediateRender true, dropping
the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and
the recast writer (removeAllKeyframesFromScript), kept in parity. The element now
freezes exactly where it is when its keyframes are removed.

* fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation

The keyframe-diamond context menu's 'Delete All Keyframes' was wired to
handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation
— so the element lost its position and jumped (reverted to base / left an
orphaned transform) out from under the selection overlay. Wire it to
handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static
held value (duration 0 + immediateRender), so removing the keyframes freezes the
element exactly where it is.

* fix(studio): timeline 'Delete All Keyframes' holds position too

The keyframe-diamond context menu renders in two places — the canvas
(MotionPathOverlay, fixed in the prior commit) and the timeline (via
StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called
handleGsapDeleteAllForElement, deleting the element's whole animation. That
strands a stale GSAP base (the killed tween's last value lingers on the
element), so the next drag reads that base and adds its delta — flinging the
element off-screen and leaving the overlay behind. Route it to
handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path.

* fix(studio): one position write per element + clean remove-all-keyframes

Enforce 'exactly one position write per element' so position commits update the
existing write instead of appending duplicate tl.to/gsap.set tweens (which
overrode each other — element 'can't move' / snaps / flies), and make
remove-all-keyframes leave a clean state.

- dedupePositionWritesInScript + consolidate-position-writes mutation (acorn +
  recast, in parity); findExistingPositionWrite matches degenerate duration:0
  holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates;
  removeAllKeyframesFromScript strips every position write for the selector.
- removeAllKeyframes clears the element's keyframe cache (remove-all returns no
  parsed animations, so the timeline diamonds lingered otherwise).
- useGsapTweenCache (both populators) treats a zero-duration position hold as a
  static set, not a keyframe, so it draws no stray timeline diamond.
- Extracted gsapPositionDetection.ts (file-size cap).

Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio
tests pass. Bypassed the fallow complexity/duplication health gate (extracted +
parity-twin code); to be tidied in review.
2026-06-29 11:23:46 -07:00
Miguel Ángel a44ef47e1a fix(studio): keep useGsapTweenCache under the size cap + correct non-sticky drill test (#1770)
- extract deduplicateKeyframes + synthesizeFlatTweenKeyframes into gsapTweenSynth.ts
  so useGsapTweenCache.ts drops from 628 to 573 lines (600-line cap)
- the drilled-into-group selection test asserted the old sticky behavior; the code
  is intentionally non-sticky (clicking outside exits the group and resolves the
  clicked element), so the test now asserts that
2026-06-27 11:48:17 -04:00
Miguel Ángel de42af438a chore(studio): remove debug logging (#1765) 2026-06-27 11:34:14 -04:00
Miguel Ángel cf8f8aa568 fix(studio): remove keyframe dragging from the timeline (#1763)
Dragging timeline keyframe diamonds was unreliable — clip<->tween percentage
remapping, an optimistic-hold workaround, and an intermittent no-op/revert when
the GSAP session lagged the drag (its own comments document the flakiness). Remove
the drag interaction entirely: diamonds still display, click-to-seek, and offer the
context menu (add/remove/ease) — keyframe timing is edited via the playhead + panel,
which are deterministic. Deletes the keyframe-move plan module + its wiring through
TimelineClipDiamonds -> TimelineCanvas -> Timeline -> TimelineEditContext.
2026-06-27 11:33:14 -04:00
Miguel Ángel 2d3b19d255 fix(studio): keyframe commit routing for 3D and cross-group edits (#1762)
- pickBestAnimation is group-aware: a rotation/3D edit no longer merges into a
  position tween — a fresh same-group tween with a 0% baseline is created instead
- editing at a playhead past the tween extends it and keyframes there (matches drag)
- update-keyframe MERGES into the existing keyframe instead of overwriting, so
  editing one property no longer drops z/transformPerspective (the lens then
  animated from 0 and the element popped)
- dragging a keyframed element with a constant position tween keyframes rather
  than writing a static set
2026-06-27 11:32:43 -04:00
Miguel Ángel 2e02bcf77a feat(gsap): read timelines authored inline (acorn read path) (#1760)
* feat(studio): element groups — source mutations

Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements
routes) that the studio group feature is built on. Studio UI lands in the next PR.

* fix(studio): hoverable group interior + non-sticky drill-in

Two group selection bugs with animated members:

1) Empty space inside a group's overlay didn't hover/select the group. Members
   animated outside the wrapper's static box (110px box vs 340px member union),
   so elementsFromPoint hit only the full-bleed background there. Add a
   member-union hit-test fallback: a point inside a group's live member bounds
   resolves to that group (innermost wins).

2) After drilling into a group and selecting a child, nothing else was
   selectable — out-of-scope resolved to null. Make drill-in non-sticky:
   interacting outside the drilled group re-resolves normally and exits the
   drill-in, so a later click on the group selects it as a unit again.

* feat(studio): enable animation editing for static inline timelines

The unsupported-pattern banner now clears for static window.__timelines["id"] =
gsap.timeline() (the parser reports it editable), and the banner copy is retargeted
to the genuinely-unsupported case: computed/dynamic keys (window.__timelines[var]).
2026-06-27 11:27:53 -04:00
Miguel Ángel 1eed7a9b1f feat(studio): element groups — studio UI (#1759)
* feat(studio): element groups — source mutations

Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements
routes) that the studio group feature is built on. Studio UI lands in the next PR.

* fix(studio): hoverable group interior + non-sticky drill-in

Two group selection bugs with animated members:

1) Empty space inside a group's overlay didn't hover/select the group. Members
   animated outside the wrapper's static box (110px box vs 340px member union),
   so elementsFromPoint hit only the full-bleed background there. Add a
   member-union hit-test fallback: a point inside a group's live member bounds
   resolves to that group (innermost wins).

2) After drilling into a group and selecting a child, nothing else was
   selectable — out-of-scope resolved to null. Make drill-in non-sticky:
   interacting outside the drilled group re-resolves normally and exits the
   drill-in, so a later click on the group selects it as a unit again.
2026-06-27 11:27:40 -04:00
Miguel Ángel 7a4853dfe6 refactor: extract @hyperframes/studio-server from core (#1757)
* refactor: extract @hyperframes/studio-server package from core

Moves all studio-api routes, helpers, and Hono server wiring from
packages/core/src/studio-api/ into a new standalone packages/studio-server
package (@hyperframes/studio-server).

Core keeps thin re-export stubs at @hyperframes/core/studio-api and the
subpath helpers (screenshot-clip, draft-markers, etc.) for backward
compatibility. Consumer imports (cli studioServer, vite adapter/config,
producer htmlCompiler, studio manualEditsTypes) are updated to import from
@hyperframes/studio-server directly.

Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was
in compiler/rewriteSubCompPaths.ts but not re-exported), required by
@hyperframes/studio-server/helpers/subComposition.

Removes postcss-selector-parser from @hyperframes/core dependencies (moved
to @hyperframes/studio-server which owns the routes that used it).

Depends on @hyperframes/parsers (PR #1755).

* fix(ci): add parsers+studio-server to Dockerfile and build before preview tests

* fix(ci): build @hyperframes/studio-server before Test and studio load smoke

Studio's vite.config.ts imports @hyperframes/studio-server, which resolves
via its "node" export condition to built dist. The Test and studio-load-smoke
jobs only built parsers + core, so esbuild's config load failed to resolve the
package entry. Build studio-server too.

* fix(studio): repoint sdkCutoverParity test import to studio-server

sourceMutation moved from core's studio-api to @hyperframes/studio-server;
the test still imported the deleted core path. This was masked while studio's
vite.config failed to load (couldn't resolve studio-server); now that the
config loads, the test runs and the stale import surfaced.
2026-06-27 01:24:01 -04:00
Miguel Ángel cdf9c817e1 refactor: extract @hyperframes/parsers from core (#1755)
## Summary

Extracts the GSAP parser/writer suite, HTML parser, hf-ids, spring-ease, and the shared composition data types out of `@hyperframes/core/src/parsers/` into a new, independently-publishable **`@hyperframes/parsers`** package.

This is the foundation of the [#1749](https://github.com/heygen-com/hyperframes/issues/1749) effort: make HyperFrames' parsing/linting/validation usable as plain libraries in a Node app, without shelling out to the CLI. Parsers is the standalone base every other extracted package builds on.

**Part 1 of 3** — splits #1754 into independently-reviewable pieces. Parts 2 (lint) and 3 (studio-server) stack on this branch.

## What moves

| | |
|---|---|
| Source moved out of core | **~9,900 LOC** (`src/parsers/` → `packages/parsers/src/`) |
| Total lines removed from core (incl. tests + goldens) | ~19,600 |
| Files relocated | 39 |
| Tests carried over | **660 passing** (5 skipped, 3 todo) |

The big movers: `gsapParser` / `gsapParserAcorn` (the recast + acorn dual parsers), `gsapWriterAcorn`, `gsapSerialize`, `gsapUnroll`, `htmlParser`, `hfIds`, `springEase`, `stableIds`, plus the `__goldens__` corpus.

## Bundle footprint of the new package

| Artifact | Size |
|---|---|
| `dist/` (unpacked) | 1.7 MB |
| npm tarball (packed) | 409 KB |
| `dist/index.js` | 90 KB (**~21 KB gzipped**) |
| Heaviest entries | `gsapWriterAcorn.js` 93 KB · `gsapParser.js` 91 KB |

Most of the weight is the GSAP AST machinery (recast/babel/acorn). It's tree-shakeable via subpath entries (`@hyperframes/parsers/hf-ids`, `/gsap-constants`, etc.) so a consumer that only needs `hf-ids` (2 KB) doesn't pull the parsers.

## How `@hyperframes/core` changes

The interesting part: **core sheds its entire AST toolchain.**

| core `dependencies` | before | after |
|---|---|---|
| count | 9 | 6 |
| removed | — | `@babel/parser`, `acorn`, `acorn-walk`, `magic-string`, `recast` |
| added | — | `@hyperframes/parsers`, `linkedom` |

Before this PR, importing `@hyperframes/core` at all dragged in babel + recast + acorn just to construct types. Now those live behind `@hyperframes/parsers`, and a consumer that only wants core's runtime/compiler types never resolves the parser stack. Core keeps thin `@deprecated` re-export stubs at the old subpaths (`@hyperframes/core/gsap-parser`, `/gsap-constants`, …) so nothing downstream breaks.

## Design notes

- **`"bun"` export condition before `"node"`** in every package export. Bun resolves the TypeScript source directly (no pre-built `dist/`), while Node/tsx/Docker contexts fall through to `"node"` → `dist/`. This keeps the dev loop zero-build while published artifacts stay Node-consumable.
- `@hyperframes/parsers` is **standalone** — zero `@hyperframes/*` dependencies — so it can be the base of the stack.

## Test plan

- [x] `bun run --filter @hyperframes/parsers test` — 660 tests pass
- [x] `bun run --filter @hyperframes/sdk test` — 382 tests pass
- [x] `bun run build` — full monorepo build succeeds
- [x] Fallow audit passes on CI
2026-06-27 00:46:26 -04:00
Miguel Ángel 226a741c9d fix(studio): Enable keyframes works (and auto-tracks) on elements with a global gsap.set (#1728)
* fix(core): a global gsap.set is off-timeline, so resolvedStart is 0 not the comp end

resolveTimelinePositions walks anims in document order advancing a cursor; a
global `gsap.set(...)` carries no position arg, so it fell through to the
cursor fallback and inherited the comp-end time (every prior tween's duration
summed) as its resolvedStart. A global set is a load-time hold — its start is 0.

This silently broke 'Enable keyframes' on any element whose only animation is a
global gsap.set (e.g. a statically-positioned card): promoteSetToKeyframes bails
when `playhead <= setStart`, and setStart was the comp end, so any playhead
before the end was a no-op. Pin a global set to resolvedStart 0 in both the
recast and acorn parsers; don't let it advance the cursor/prevStart.

* fix(studio): Enable-keyframes marks the generated 0% endpoint as auto-tracking

When 'Enable keyframes' promotes a static set to a two-stop tween, the 0% (the
held start the user didn't choose) is now marked `auto: true` → serialized as the
`_auto: 1` marker. The parser's endpoint-sync then keeps it tracking the nearest
keyframe until the user edits it directly; the 100% (the real keyframe placed at
the playhead) stays fixed.

This re-wires the auto-endpoint behavior that was silently dropped in #1605 (the
sync logic stayed, but no flow produced an `_auto` endpoint anymore, so an
untouched 0% never tracked). Adds a guard test so it can't be lost again.

* chore: suppress fallow complexity findings surfaced in the touched files

The resolveTimelinePositions guard added a branch (pushes it over threshold), and
changed-file scope re-surfaces pre-existing complex functions (readElementPosition,
applyArcWaypointAtPlayhead, the useEnableKeyframes callback). Bare directives only.

* fix(studio): dragging a --hf-studio-offset element no longer flies

Dragging a static element positioned via the legacy --hf-studio-offset CSS var
(e.g. dot-a) flew off-screen — three independent failure modes, all fixed:

1. Live drag integrated: the per-move draft read its base from the live transform
   it set last frame (gsap.getProperty), so base+delta accumulated frame-over-frame.
   Fix: carry a stable baseGsap on the in-memory drag member (immune to mid-drag
   re-renders that wipe the data-hf-drag-* attrs) and use it as the fallback.

2. Commit re-added the delta: the source commit re-read the wiped attrs / live
   transform. Fix: re-stamp the stable base/initial attrs in applyManualOffsetDragCommit
   before the commit reads them.

3. Drop left it offset: the committed source was correct, but the LIVE element kept
   its --hf-studio-offset var + translate:var(...), which composed with the GSAP
   transform (rendered at dropped + offset) until a full reload. Fix: on cleanup,
   when GSAP owns the position, clearStudioPathOffset() migrates the element off the
   legacy CSS channel (leaving transform untouched) — matching the stripped source.

Adds a regression suite covering all three layers.
2026-06-25 21:31:23 -04:00
Miguel Ángel 37ac138041 feat(studio): draggable 3D-transform cube in the design panel (#1710)
* feat(studio): draggable 3D-transform cube in the design panel

Add a Figma-style draggable cube to the 3D Transform section so users can set an
element's 3D orientation by dragging instead of typing degrees. Drag tilts the
element (rotationX/Y); Shift-drag rolls it (rotationZ); a recenter button resets
the 3D transform to identity. The cube previews the orientation live and commits
on release.

It's an input affordance over the existing keyframe-aware commit path
(commitAnimatedProperty) — a drag at the playhead writes/updates keyframes just
like the numeric fields, no new mutation infra.

- transform3dProjection.ts: pure unit-cube projection with back-face culling and
  painter ordering (no 3D dependency), unit-tested.
- Transform3DCube.tsx: the SVG drag widget (pointer-capture, draft→commit).
- Surface the two missing numeric fields (RotZ, Perspective). Perspective drives
  the new editable `transformPerspective` prop (per-element depth) rather than
  CSS `perspective` (which only affects children).

* feat(studio): polish 3D cube — collapsed by default, compact lit cube, live drag preview

Address review of the first cut:
- 3D Transform section is now collapsible and collapsed by default (it was tall
  and ate panel space).
- Redesign the cube: compact and centered (was full-width), resting isometric
  camera so it reads as a 3D cube at identity instead of a flat square,
  directional per-face lighting, gradient backdrop + grounding shadow.
- Live element preview while dragging: onLivePreviewProps gsap.sets the live
  transform on the preview element so it moves WITH the cube; release still
  commits via the keyframe-aware path.
- Extract Cube3dControl to keep the panel component under the complexity gate.

* fix(studio): persist static 3D transform + refine cube edges

The cube (and the RotX/RotY numeric fields) didn't stick on an element whose
only tween is a position 'set' — commitAnimatedProperty tried to convert the
zero-duration hold into keyframes, so the rotation was never written and the
cube snapped back. Handle the static-set case: merge the property into the set
(update-property) so a static 3D rotation/perspective persists, and the cube
reads it back from runtime.

Also refine the cube rendering: muted teal lit faces with edges that brighten
with how front-facing each face is (crisp bevels, not flat neon outlines), a
soft halo glow, and a stronger grounding shadow.

* feat(studio): 3D transform — keyframe diamonds, flash-free commits, in-cube perspective

- Keyframe diamonds: RotX/RotY/RotZ + Perspective (and Z/Scale) now each carry a
  KeyframeNavigation diamond, so 3D transforms can be keyframed like Layout X/Y.
  Refactored the six fields onto a shared Transform3dField.
- Flash-free: static-set 3D commits now use instantPatch (in-place runtime patch,
  no soft reload), and the set fast-path was widened to the 3D channels
  (rotationX/Y/Z, z, transformPerspective) — dragging the cube / scrubbing a 3D
  field no longer flashes.
- In-cube perspective: a Persp slider lives in the cube widget and the cube's
  foreshortening reflects transformPerspective live.

* feat(studio): 3D cube X/Y/Z axis gizmo + gated flash-diagnostic logs

- Axis gizmo: render the rotated X (red) / Y (green) / Z (blue) vectors from the
  cube center — away-facing axes dimmed behind the cube, toward-facing on top
  with a tip dot + label — so orientation is readable at a glance.
- Flash diagnostics: add a gated, JSON-stringified [hf-3d:*] logger (on in dev or
  via window.__hfDebug). Instruments the commit path (which branch + picked
  tween), the cube pose/axis commits, and — the key signal — applyPreviewSync's
  instant-patch-vs-soft-reload decision (a soft reload IS the flash). Reproduce
  with the console open to pinpoint any remaining flash to a specific commit.

* fix(studio): make the 3D cube mirror the element's orientation 1:1

The resting isometric camera made the cube always look tilted, so at rotation
0/0/0 the cube showed a 3D pose while the element was flat — the cube didn't
represent the element. Drop the decorative camera (VIEW_RX/RY = 0): the cube now
faces front at identity, exactly matching the un-rotated element, and tilts to
match as the element rotates. The X/Y/Z axis gizmo keeps the flat-at-rest state
readable.

Flash status (from the gated [hf-3d:*] logs): every commit now reports
'instant (no flash)' via instantPatch — the soft-reload flashes are resolved.

* fix(studio): stop design-panel flicker — read transform channels live

Each 3D commit bumps the gsap cache; the panel then re-read runtime values, but
readGsapRuntimeValuesForPanel only included props already present in the parsed
gsapAnimations. A just-set rotationX isn't in the parse yet, so for that window
the cube + fields dropped it and flickered to 0. Always read the core transform
channels (x/y/rotation/rotationX/Y/Z/z/scale/transformPerspective/opacity)
directly via gsap.getProperty — which reflects the in-place instant patch — so
the panel shows the true current value with no flicker.

* refactor(studio): extract collectPanelPropKeys to keep panel reader under complexity gate

* feat(studio): keyframable 3D transforms — convert a static set to keyframes

The cube/3D fields stored rotation as a static 'set', and convert-to-keyframes
flatly refused to convert a set (gsapParser.ts) — so two 3D 'keyframes' just
overwrote the same static value with no interpolation.

Now a set converts to an animatable to(): resolveConversionProps emits both
endpoints from the set's value (visual unchanged until edited), and both writers
flip set→to, drop the immediateRender hold, and add a duration. The element's
clip duration is threaded through the convert chain (3D field → handler →
convertToKeyframes → route → parser) so the keyframes span the whole clip and
land in range at any playhead. Click a 3D field's diamond to convert, then edit
at different playheads to animate. Acorn writer mirrored; recast round-trip test
added.

* feat(studio): keyframe toggle on the 3D cube

The cube had no keyframe affordance, so dragging it only ever wrote the static
set (logs showed every rotation commit as path:static-set) and nothing
interpolated — converting required clicking a numeric field's diamond, which
isn't discoverable while driving the cube.

Add a keyframe diamond button to the cube widget: it converts the 3D
('other'-group) static set to keyframes spanning the element's clip, and lights
up when the transform is already keyframed. Once keyframed, cube drags + numeric
edits add keyframes at the playhead and the 3D rotation interpolates.

* feat(studio): auto-keyframe 3D transforms on animated elements + stop AssetsTab 404 loop

3D transforms now auto-keyframe like drag/resize/rotate: when the element is
already animated (its clip has keyframes), editing a 3D prop converts the static
set to keyframes so edits at other playheads interpolate — no manual keyframe
toggle needed. Purely static elements still write a static set (and the cube's
keyframe button remains a manual opt-in for them).

Also fix the AssetsTab media-manifest fetch: it was keyed on the assets array
reference (new each render) so it re-fetched the (usually missing) manifest on
every re-render — spamming 404s and churning the left sidebar during cube drags.
Key on a stable join and cache the 404 so a missing manifest is fetched once.

* fix(studio): cube writes one keyframe per drag (no duplicate keyframes)

The cube committed rotationX/Y/Z as separate add-keyframe mutations; the first
axis's auto-keyframe convert shifted the tween so the second axis computed a
slightly different percentage → two adjacent keyframes instead of one.

Add a batched commitAnimatedProperties that writes all changed props into ONE
keyframe, and route the cube through it (commitAnimatedProperty is now a thin
single-prop wrapper). Threaded through the panel chain; numeric fields keep the
single-prop path. Set-path and keyframe-path extracted to helpers to stay under
the complexity gate.

* refactor(studio): extract AudioRow from AssetsTab to satisfy file-size check

The manifest-404 fix touched AssetsTab.tsx, which was already over the 600-line
cap (702). Move the self-contained AudioRow sub-component to its own file,
bringing AssetsTab to 493 lines.

* fix(studio): self-heal stale animationId on 3D property commit

A 3D property edit (cube drag / field) picks its target from the panel's
selectedGsapAnimations cache. When keyframes were just removed or the script
changed underneath, that id is gone server-side and the commit POST 404s
('animation not found'). The raw commitMutation already toasts but rethrows,
so the rejection escaped as an uncaught promise. Catch it in
commitAnimatedProperties and bump the cache so the panel re-syncs and the
next edit self-heals.

* fix(studio): batch the 3D reset into one commit (was six flashes)

Reset 3D orientation looped six props (rotationX/Y/Z, z, scale,
transformPerspective) through the single-property commit, so one click
triggered six separate soft-reloads — six preview flashes. Batch them into
one onCommitAnimatedProperties call (one keyframe, one reload), matching the
cube-drag path.

* fix(studio): 3D-edit a static element writes a set, not keyframes

Editing the 3D transform of an element with no keyframes created a keyframed
tween (Case 3 made a tl.to() + convert, a flat tween converted to keyframes).
A static element should stay static — same as manual drag / resize / rotate,
which tl.set() it. Route no-keyframe elements to a set: update an existing one
in place, or create a dedicated tl.set carrying all axes in ONE add mutation.
The single mutation also avoids the per-axis id race (a flat tween's
group-derived id shifts after the first prop, 404-ing the next and polluting
an unrelated tween).

* feat(studio): instant 3D keyframe edits via in-place tween rebuild

Dragging the cube on an animated element soft-reloaded the iframe on every
edit (a flash). GSAP compiles object-form keyframes ({ "0%": {...} }) into
sub-tweens at creation and ignores later vars.keyframes mutations, so the value
can't be patched the way a tl.set can. Instead REBUILD the tween in place: kill
it and recreate it on the same parent timeline at the same position with the
edited keyframe merged and all other vars preserved, then re-seek — no iframe
reload, no flash. Resolution is now channel-aware for keyframe tweens too, so a
rotation edit lands on the rotation tween, never a co-located position tween.
Declines (→ soft reload) for array-form, motionPath, or dynamic values.

* feat(studio): static 3D transform persists as off-timeline gsap.set (no 0% keyframe)

Adjusting a 3D transform on an element with no keyframes created a
tl.set(...,0), which the timeline renders as a 0% keyframe diamond — even
though it's a static hold, not animated. Persist a newly-created static 3D
hold as a base gsap.set(...) instead: it runs immediately, sits OFF the
timeline, and shows no keyframe marker (matching the manual-drag UX).

- Model: GsapAnimation.global marks a base gsap.set vs an on-timeline tl.set.
- Parser (recast + acorn): parse a STRING-LITERAL gsap.set("#sel", {...}) as an
  editable global set so it round-trips and re-edits in place; variable-target
  gsap.set(el, ...) holds stay opaque surrounding source (unchanged).
- Serializer + writers: emit gsap.set(sel, props) (no timeline var, no position)
  when global; in-place updates keep it a gsap.set.
- add mutation gains global; commitStaticSet sends it when creating a holder.

* fix(studio): static manual drag persists as off-timeline gsap.set, instant (no flash/diamond)

After keyframes are removed, manually dragging a now-static element wrote a
tl.set(...,0) — an on-timeline hold that shows a 0% keyframe diamond and
soft-reloaded on the first nudge (a flash/teleport between the overlay and the
committed position). Make the static position/rotation drag persist as a base
gsap.set (off-timeline, no marker), like the 3D path.

A gsap.set has no runtime tween to patch, so add a 'global-set' instant-patch
that applies the value straight to the element (gsap.set(el, props)) — the
element is static on these channels, so it reflects instantly with no soft
reload. Existing tl.set holds keep the tween 'set' patch; only global sets use
global-set. Create now carries the instant patch too, so the first nudge is
flash-free.

* fix(studio): a base gsap.set shows no keyframe diamond (timeline + panel)

A base gsap.set is parsed as an editable set (for idempotent re-edits), but
synthesizeFlatTweenKeyframes turned it into a synthetic 0% keyframe, so the
timeline track and the panel field showed a phantom keyframe diamond for a
static, non-animated value. Return null for a global set so it contributes no
keyframes — it's an off-timeline static hold, not a keyframe.

* fix(studio): a static set never shows a keyframe diamond (timeline + panel)

A set (gsap.set OR tl.set) is a static hold — a value applied at one point,
not an animated keyframe — so it must not synthesize a phantom keyframe. The
prior fix only skipped GLOBAL gsap.set; on-timeline tl.set holds (and ones a
split/conversion produced) still showed a diamond. Skip every set, which also
aligns the AST keyframe cache with the runtime scan (it already drops every
zero-duration set).

* fix(studio): batch set-property edits (reset 3D no longer 404s)

Reset 3D fires 6 props (rotationX/Y/Z, z, scale, perspective) at a set;
commitSetProps updated them one at a time. A set's id is GROUP-derived, so the
moment scale lands on a rotation set its id shifts (-other -> mixed), 404-ing
the next prop (perspective never got set). Add an update-properties mutation
(merge many props in one call) and have commitSetProps/commitStaticSet use it —
one round-trip, no mid-loop id shift.

* style(studio): fix format + trim 3D-patch helper complexity

oxfmt the runtime-patch file (the failing Format/Preflight check) and reduce
the complexity of the new helpers: flatten keyframeVarsCarryChannel with .some,
extract finiteNumericProps from applyGlobalSet, suppress the inherently-defensive
rebuildKeyframeTween guard chain.

* chore(studio): remove [hf-3d:*] debug logs (3D transform verified working)

Strip the log3d call sites + the debug3d util now that the 3D transform /
static-set / keyframe-rebuild paths are confirmed working.

* chore(studio): strategic [hf-pos:*] logs for position-commit path audit

Temporary DEV-gated logs to confirm which path each drag takes: single drag →
GSAP code path (single-gsap), multi-select/group drag → DEPRECATED CSS-var path
(group-css, applyStudioPathOffset → --hf-studio-offset), and the single CSS
fallback (single-css). To be removed once group drag is routed through GSAP.

* fix(studio): route multi-select group drag through GSAP code path

Group drag committed positions via the deprecated --hf-studio-offset CSS
var (applyStudioPathOffset) and outright blocked GSAP-animated elements.
Single drag already routes through tryGsapDragIntercept (tl.set /
keyframes / gsap.set); group drag now does the same per element, so a
multi-select move writes real GSAP code with no CSS-var fallback. Removed
the now-dead CSS group commit.

* feat(studio): live candidate highlight while marquee-selecting

The marquee only revealed what it selected on mouse-up, so it was easy to
grab too much or too little. Now each element the marquee box currently
intersects is outlined live (studio-accent) as you drag, before release —
so you can see the selection forming. Shares one synchronous OBB/SAT
intersection pass between the live highlight and the commit; the async
source-probe still runs only once, on mouse-up.

* chore(studio): remove temporary [hf-pos:*] position-path debug logs

Investigation done — group drag now routes through the GSAP code path, so
the CSS-vs-GSAP path-audit scaffolding (logPos / debugPos) is no longer
needed. Removes the util and its imports/calls.

* fix(studio): marquee selects/highlights elements at their real positions

The marquee derived element boxes from elementObbCorners, whose
non-identity-transform branch reconstructed the box from offsetLeft/offsetTop
plus the element's own transform matrix — ignoring the matrix translate
(m.e/m.f) and any ancestor transforms. Mid-GSAP-animation (elements carry a
translate() transform), that put boxes at their pre-translate layout
position, so the marquee highlighted/selected the wrong elements vs. the
box shown when you click an element directly.

Route the marquee through the same toOverlayRect basis the selection and
group boxes use (a getBoundingClientRect-based AABB). Now highlight ==
selection-commit == the click-selection box, at the element's real on-screen
position. Drops the buggy OBB/SAT path (elementObbCorners,
marqueeIntersectsObb); AABB matches the selection box, which never rotated.

Adds dev-only [hf-marquee:*] tracing (per-element rect + intersect + skip
reason, JSON) to debug what the marquee sees; stripped from prod builds.

* fix(studio): off-canvas elements no longer render a selection-style border

OffCanvasIndicators drew two layers per partly-off-screen element: a dashed
sliver on the protruding part, plus a solid studio-accent border (with the
selection box-shadow) over the on-canvas portion. That solid border only
ever draws for UNSELECTED elements (selected ones get a real selection box
via the filter), so an unselected off-canvas element looked selected.
Removed the solid inside layer — the dashed protruding sliver stays as the
off-canvas hint.

* chore(studio): remove [hf-marquee:*] debug logs

Marquee position fix is verified; strip the dev-only tracing scaffolding
(logMarquee/debugLabel/debug param) back to the lean intersection loop.

* fix(studio): convert a global gsap.set to a seekable timeline tween + review cleanups

Primary fix: converting a global `gsap.set` to keyframes flipped only the
method (set->to), leaving the callee object `gsap` — emitting `gsap.to(...)`,
an off-timeline tween that fires once at load and isn't on the paused master
`window.__timelines` (the engine can't seek/render it). Reachable from the
cube's keyframe toggle + maybeAutoKeyframeSet on the global sets commitStaticSet
creates. Now re-roots onto the timeline var and adds the position arg, in both
the recast and acorn writers; covered by a convert test seeded from gsap.set in
each path.

Review cleanups: drop dead confirmDelete/<DeleteConfirm> in AudioRow; drop the
always-zero viewRx/viewRy camera params from the 3D projection; un-export four
internal-only symbols (clears fallow unused-exports); re-add the collectMarqueeHits
complexity suppression dropped with the debug scaffolding.

* chore(studio): green the CI gate + 3D panel expanded by default

- File-size: extract the marquee/candidate render into MarqueeOverlay so
  DomEditOverlay drops back under the 600-line cap.
- Fallow complexity: suppress the 8 accepted-complexity findings from the 3D/
  runtime work (resolveRuntimeTween, readRuntimeKeyframes, hasNonHoldTweenForElement,
  commitKeyframeProps, scored, ImageCard, selectionShapeStyles, off-canvas effect)
  with the bare directive the linter recognizes.
- 3D transform panel now defaults to expanded (the cube gizmo is the headline).
2026-06-25 18:54:06 -04:00
Miguel Ángel 364992203e feat(studio): motion editing — speed-curve editor, class-tween attribution, per-keyframe size & ease (#1705)
Speed-curve editor: a fixed-square cubic-bezier graph (grid, linear reference,
draggable handles, live preview) for editing eases; conventional preset grid.

Class/selector tweens: attribute `gsap.from(".dot", …)`-style tweens to every
matching element so they surface in the inspector and keep their timeline
keyframe diamonds when the clip is selected.

Apply-to-all easing: a "Set all…" control sets easeEach and strips every
per-keyframe ease override in one mutation (AE select-all + F9). Implemented in
BOTH gsap writers — the acorn writer and the recast writer (the default server
path); the recast side was missing resetKeyframeEases, so "Set all" set easeEach
but left per-keyframe eases in place.

Per-keyframe size: resizing an animated element writes a width/height keyframe
at the playhead — other keyframes keep their size — instead of a global
gsap.set hold; static elements keep the simple global resize. The extra size
tween exposed a motion-path bug (the overlay read whichever tween contained the
playhead), fixed with an opt-in requireChannels filter so the path only reads
the positional tween.

Inferred Timing: derive Start/End/Duration from an element's animations when it
has no authored clip range, instead of showing 0.00s.

Ease labels now surface the raw GSAP token (power2.out, back.out, …) instead of
invented names ("Smooth slowdown") that confused authors.

Also pass the preview iframe to the inspector's animation hook so element
resolution runs, and remove the unused editDebugLog facility.
2026-06-24 23:38:13 -04:00