Commit Graph
114 Commits
Author SHA1 Message Date
Miguel ÁngelandClaude Opus 4.8 6987447a75 fix(runtime): immediateRender for set tweens + array timeline normalization (#1692)
* chore(studio): remove all console.* calls from studio package

* chore(studio): address review — remove dead stubs, restore consent notice

- Delete empty if-blocks left after console removal (snapTargetCollection,
  Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording
  dev guard + now-unused isDevBuild) and the stale "surface in dev" comment.
- Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher.
- Restore the one-time telemetry consent disclosure in showNoticeOnce (kept
  behind a pragma — it is a user-facing notice, not debug noise).
- Remove the missed timelineIcons console.warn while preserving the
  `tag || "div"` null-safety fallback.
- Route caption auto-save failures (a data-loss path) through telemetry
  instead of swallowing silently.
- Restore the accidentally-clobbered css-var-fonts output.mp4 fixture.

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

* fix(runtime): immediateRender for set tweens + array timeline normalization

- Set tweens now emit immediateRender:true so they render on page load
  without requiring the runtime to seek past position 0
- Runtime IIFE normalizes array timelines (window.__timelines = [tl])
  to keyed objects, and auto-adds data-start on root elements
- Drag teardown clears translate:none to prevent #1673 fly-off
- Position-only set tweens hidden from timeline diamonds (3 cache paths)
- Parser: ease-only keyframe update preserves existing properties

* fix(runtime): address review — restore perf gate, debug surface, scrub restore

- Restore the #1651 skipForInjectedVideo gate in media.ts that was dropped on
  restack — avoids ~2400 wasted per-tick seeks on video-heavy renders.
- Restore the console.debug body + docstring bullet of swallow() in
  diagnostics.ts: the __hfDebug opt-in debug surface had been gutted to an
  empty if-block.
- Rebind: after the progress-cycle set() kick, seek to state.currentTime via
  totalTime() instead of snapping to 0, so a rebind after scrub / soft-reload
  restore keeps the playhead.
- Array __timelines normalization + data-start default now resolve the root
  via a shared findRootCompositionEl() that honors data-root="true" first
  (matches resolveRootCompositionElement, which now delegates to it).
- Ease-only keyframe update leaves a primitive (non-object) keyframe value
  untouched instead of wiping it to {}; add a preservation unit test.
- Document the boundDuration<=0 progress(1) kick + restore the STATIC-case
  comment in gsapRuntimeBridge.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:53:02 -04:00
Miguel ÁngelandClaude Opus 4.8 adb40321d6 chore(studio): remove all console.* calls from studio package (#1691)
* chore(studio): remove all console.* calls from studio package

* chore(studio): address review — remove dead stubs, restore consent notice

- Delete empty if-blocks left after console removal (snapTargetCollection,
  Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording
  dev guard + now-unused isDevBuild) and the stale "surface in dev" comment.
- Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher.
- Restore the one-time telemetry consent disclosure in showNoticeOnce (kept
  behind a pragma — it is a user-facing notice, not debug noise).
- Remove the missed timelineIcons console.warn while preserving the
  `tag || "div"` null-safety fallback.
- Route caption auto-save failures (a data-loss path) through telemetry
  instead of swallowing silently.
- Restore the accidentally-clobbered css-var-fonts output.mp4 fixture.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:49:36 -04:00
Miguel Ángel 82a5298058 fix(studio): selectedGsapAnimations empty after gesture recording (#1646)
- include target in useGsapAnimationsForElement fetch key so a selection
  change triggers a re-fetch even at the same cache version
- add gsapCacheVersion to useDomEditPreviewSync deps so the selection
  re-syncs after every soft reload
- trim manualEditsDom.ts to 600 LOC (filesize compliance)

Fixes #1645
2026-06-22 14:06:53 -04:00
Miguel Ángel 1f5dbdbc4c fix(studio,producer): post-merge regressions and open issues (#1643)
- fix gsapDragCommit.test.ts import for commitGsapPositionFromDrag
- add generateId() helper with insecure-context fallback (fixes #1637)
- enable STUDIO_KEYFRAMES_ENABLED by default
- prevent clock duration from shrinking during playback (fixes #1636)
- add maxRetries to rmSync cleanup calls to handle ENOTEMPTY race (fixes #1644)
2026-06-22 11:19:52 -04:00
Miguel Ángel 091137e3c3 feat(studio): instant, flicker-free manual editing (#1605)
* chore(producer): shim __filename/__dirname in the CJS banner

Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.

* chore(producer): use a template literal for the CJS banner (review nit)

* feat(core): add GSAP keyframe + motion-path source mutations

Array-form keyframe removal in both the recast and acorn writers, plus
update/add/remove-motion-path-point and add-motion-path. Exclude _auto and
data from tween property-group classification.

* fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks

- Regression test for the `data` GSAP-key exclusion (parallel to _auto).
- splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the
  from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests.
- addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated.
- Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring
  removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug —
  documented as it.skip with a ready assertion (acorn cutover follow-up).

* feat(core): route motion-path mutations through studio-api + fix clip stamping

Wire the new mutations into the file save route. Only authored clips suppress
descendant stamping, so auto-stamped animated scenes can inline-expand.

Hide in-flow timed clips with `display:none` only when they are LEAF clips (no
nested timed clips). `display:none` on a container removes its whole subtree,
hiding descendants that are still inside their own visibility window — e.g. an
in-flow composition root whose effective window clamps to the timeline end would
black out a child video that should still show (the hdr-hlg regression).
Containers keep `visibility:hidden`, which a visible descendant can override; only
leaves leave the flow, which is all the split-overlap case needs.

* feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule

A position or rotation add/set mutation makes the GSAP timeline the single source of
truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS
var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both
channels, and the add-strip fires for the position AND rotation property groups.

Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio
cannot save drag/resize edits to elements in a registered timeline — the exact premise
the single-source work inverts (the timeline is now the edit target). Removed the rule,
its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests.

* fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel

- HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript
  authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta,
  shift-positions, scale-positions, split-animations. (add stays out: flat tweens
  only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.)
- init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change;
  visible/hidden branches both go through isTimedClipInFlow (was .get() by accident).
- keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set
  strips the stale --hf-studio-rotation channel.

* feat(studio): GSAP runtime read layer + shared helpers

* fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests

- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
  (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
  ease is stripped without shifting the other keyframes' percentages.

* feat(studio): GSAP drag/commit/bridge editing infra

* fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore

BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the
commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the
server save, fixing both consumers (useEnableKeyframes + useGestureCommit's
showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation
return type widened void→Promise<void> (fire-and-forget consumers ignore it).
- stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live).
- commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek,
  so a failed commit leaves the dropped pose, not a cleared element.

* feat(studio): motion-path geometry + commit helpers

* docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics

* feat(studio): on-canvas motion-path overlay

* fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold

* feat(studio): keyframes flag, gesture recording + timeline/selection refinements

* fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens

- useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's
  empty parse isn't overridden by a stale selectedGsapAnimations cache.
- useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick).
- useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent).
- playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV).
- useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per
  group, so a mixed gesture no longer yields an untagged legacy tween.

* feat(studio): single-source manual offset + rotation via the GSAP timeline

Dragging or rotating an element writes into the GSAP timeline (the single source of
truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static
elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and
the live preview moves via gsap.set so what you see equals what is written and renders.
Removes the dual-channel CSS-var/transform reconciliation behind the
fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position
and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/
keyframe commit, dropped the handleDom*Commit CSS fallbacks).

Subcompositions edit the same single-source way, which surfaced and fixes:
- resolve a subcomp element's source file via the composition-id map (the runtime drops
  the source linkage when inlining the subcomposition);
- a selected element's selection box AND motion path use basic visibility, not the
  occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover);
- soft reload rebuilds ONLY the committed composition's timeline, leaving other
  compositions' timelines intact (no cross-composition revert);
- read keyframes from the element's OWN composition timeline (scan all timelines, not
  the first unstable key);
- delete-all uses a soft reload too, so editing no longer hard-reloads the iframe.

* fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures

- DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only
  position/rotation channel; the false branch silently killed drag+rotate (and let GSAP
  elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests.
- gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller
  full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script.
- gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops
  partial reads (no phantom identity) and re-applies the drag override in finally.
- MotionPathOverlay: park-timer cleanup keyed on animId change.
- useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review).

* feat(studio): patchRuntimeTweenInPlace — update a tween's values in place

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.

* fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.

* feat(studio): instantPatch fast path in runCommit

A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on
success the preview updates in place with NO reload (instant), on false it falls back
to the existing soft reload. Extracts the preview-sync tail into a testable
applyPreviewSync helper. No behavior change when instantPatch is absent.

* feat(studio): route static position/rotation set drags through instantPatch

Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).

* fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)

* feat(studio): no full iframe remount for soft-reloadable edits

A softReload edit (and the SDK single-script refresh) no longer escalates to a full
reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set
already shows the value, and a remount is the worst flash + re-inlines subcomps
(reverting their keyframes). verifyTimelinesPopulated now checks the expected target
keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty.
Full reload stays only for the structural (no-softReload) and ambiguous-script paths.

* feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash

ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout
onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the
timeline. So when a user adds a motion path to a composition that didn't originally
use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN
async path (the flash). Idempotent + defensive; the existing async fallback stays for
genuine cold-start/CDN-failure.

* fix(studio): don't re-save + reload when source editor syncs externally

The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged —
including the programmatic dispatch that syncs external content (e.g. a manual-edit
commit writing the source back into the open editor). That made the editor re-save the
file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe
edit — defeating the in-place instant patch and causing the flash. Annotate the
programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save.

* fix(core): inject MotionPathPlugin into preview when a composition uses motionPath

A studio-created motion path writes a gsap motionPath tween into the single-source
timeline, but the preview HTML only loaded gsap core — so the first render threw
"Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject
MotionPathPlugin right after the composition's gsap script, version-matched to it.

* fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact)

* fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard

BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' |
'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full
reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script,
or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the
TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome
(origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted.
- SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an
  in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused).
- Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append).

* fix(core): align __clipTree and __clipManifest ids via stableClipId

Timeline inline expansion was dead for nested children inside index.html:
the tree keyed id-less elements by a synthetic __clip-N while the manifest
keyed them null, so parent<->child never joined. Both now resolve identity
through stableClipId (id || data-hf-id), which every generated element has.

* fix(core): strip baked runtime + tag comp root in preview assembly

Comps that ship a baked inline runtime were double-loaded (preview injects
its own) and the baked copy failed to parse inline (Unexpected token '<').
Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also
tag the comp root with data-composition-file so the studio resolves a comp's
top-level elements to the right source file instead of defaulting to
index.html (which made the GSAP panel parse the wrong, multi-timeline file).

* feat(studio): set motion-path destination from a toolbar toggle

Replaces the double-click-on-canvas UX (which painted text over the preview)
with a 'Set motion destination' toggle next to Snap/Grid, shown only when the
selected element can take a path. While armed, one canvas press places the
destination. Also removes the dead TimelinePropertyRows component.

* fix(studio): center timeline keyframe diamonds on their percentage

Dropped clampDiamondLeft, which forced boundary keyframes fully inside the
clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's
midpoint now sits exactly on its % (the clip is overflow-visible).

* fix(studio): resize static elements via tl.set, not a single-stop keyframes tween

Resizing an element with no size animation wrote keyframes:{ <playhead%>:
{width,height} } — one mid-point stop GSAP can't interpolate, so it rendered
NaN/0 dimensions at every other frame and the element vanished (worst off 0%).
Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize
now writes tl.set({width,height}), held at all frames; re-resizing updates it
in place.

* fix(studio): negative-cache failed media probes

Only successful probes were cached, so CORS/404 cross-origin media was
re-probed every rAF-driven timeline re-derive, flooding the console. Remember
failed URLs and skip them.

* fix(studio): type window.setTimeout handle as number

ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is
present and clashes with the DOM number the call returns. Type it number.

* fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps

- Fix soft-reload clearProps destroying element inline styles — save cssText,
  clear, restore, strip only transform
- Fix resize no-op on re-resize: delete+add instead of two update-property
- Route set tweens through static resize path (convertToKeyframes skips sets)
- Re-fetch animation ID before drag commit to prevent stale-ID duplicates
- Guard editDebugLog for Node test environments
- Fix NLELayout setState-during-render (move reset to useEffect)
- Stop SnapToolbar pointer events propagating to canvas deselect handler
- Enable click-to-add waypoints on cubic motion paths
- Add whole-path drag offset (Alt+drag shifts all keyframes together)
- Add Canvas shortcuts section to ShortcutsPanel
- Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance)
- Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
2026-06-22 01:21:18 -04:00
Miguel Ángel c0ffdc0fb0 fix(core,studio): escape user values in querySelector attribute selectors
Extract cssAttrSelector to packages/core/src/utils/cssSelector.ts and
use it (or CSS.escape for browser-side code) at all 12 sites that
previously interpolated raw user-authored values into querySelector
attribute selectors. A " in a composition ID, script src, or
data-start value would produce a malformed selector that throws.

Node-side (core compiler/parser): uses the shared cssAttrSelector.
Browser-side (runtime, studio): uses native CSS.escape().

Supersedes #1568 which fixed only the 3 bundler sites.
2026-06-19 15:47:28 -04:00
Vance IngallsandClaude Opus 4.8 933b88ec33 feat(studio): resolver-parity shadow tripwire (decoupled telemetry) (#1547)
* feat(studio): resolver-parity shadow tripwire (decoupled telemetry)

New sdkResolverShadow.ts module: checks whether the SDK session resolves
the same element id the server path would address, then verifies value
parity after in-memory dispatch. Emits sdk_resolver_shadow telemetry on
divergence. Decoupled from STUDIO_SDK_CUTOVER_ENABLED via its own flag
STUDIO_SDK_RESOLVER_SHADOW_ENABLED (default false).

Headline signal: element_not_found — the resolver divergence class that
caused the v0.6.110 regression. Writer-parity suite (#1533) cannot see
this class; this tripwire exists specifically to catch it.

All 12 acceptance-test-plan items pass (A1-A3, B4-B6, C7-C10, D11, E12).

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

* fix(studio): default STUDIO_SDK_RESOLVER_SHADOW_ENABLED to true

Tripwire should run out of the box — operators opt out, not in.

* fix(studio): resolver shadow must not mutate the live session (restore via inverse patches)

The shadow runs on the SAME sdkSession the cutover path uses, one line before
sdkCutoverPersist. sdkResolverShadowCheck dispatched the edit into that session
to read values back but never undid it — so with the shadow enabled the edit was
pre-applied, and sdkCutoverPersist then saw before === after and silently fell
back to the server path. Enabling the tripwire disabled cutover.

Fix: capture the inverse patches of the shadow dispatch (session.on("patch")) and
applyPatches them to restore the session before returning, on every path
(success, dispatch_error, element_not_found after dispatch). The session ends the
check exactly as it started; cutover's before/after diff is unaffected.

Tests: B5 now asserts the live session is restored (color back to original, not
left on the shadow value) and B5b proves a cutover-style before/dispatch/after
diff still fires after a preceding shadow run. The earlier B5 used two separate
sessions and so never exercised the shared-session path the bug lived in.

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

* feat(studio): extend resolver shadow to timing/delete/gsap-add chokepoints

The shadow only ran on the DOM-edit path (inline-style/text/attribute via
onTrySdkPersist) — blind to the rest of the cutover surface, which is where the
resolver bugs that motivated it actually live (v0.6.110 was a GSAP property op;
CF2 #15/#16 were timing-resolver bugs). On-for-everyone telemetry that only sees
style/text/attr edits misses the riskiest paths.

Adds a read-only element-resolution tripwire (recordResolverParity) — emits the
headline `element_not_found` signal when the SDK can't resolve a target the
server path is addressing, with NO dispatch/mutation. Wired before the cutover
gate (decoupled) in the element-targeted chokepoints: sdkTimingPersist,
sdkDeletePersist, and sdkGsapTweenPersist's add op.

To avoid a circular import (sdkResolverShadow imported patchOpsToSdkEditOps from
sdkCutover; sdkCutover now imports recordResolverParity from sdkResolverShadow),
patchOpsToSdkEditOps moves to a neutral sdkOpMapping.ts that both import from.

animationId-resolving GSAP ops (set/remove tween, keyframe ops, deleteAllForSelector)
resolve an animation, not an element, so element-resolution parity doesn't apply —
left as a follow-up (separate animation-resolution signal).

Tests: recordResolverParity emit-on-divergence / parity-no-op / flag-off-no-op /
read-only (no mutation). Full studio suite green.

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

* feat(studio): resolver shadow covers animationId GSAP ops (animation_not_found)

Extends the tripwire to the GSAP-edit surface that resolves an animationId
rather than an element: setGsapTween/removeGsapTween, addGsapKeyframe,
removeGsapKeyframe, removeGsapProperty, removeAllKeyframes, convertToKeyframes.

Adds recordAnimationResolverParity — read-only, emits the new `animation_not_found`
kind when the SDK can't resolve the animationId the server GSAP path is
addressing. The SDK's resolvable animation ids are the located ids attached to
elements (buildAnimationIdMap), so a target absent from every element's
animationIds is a resolver divergence. No dispatch, no mutation.

Wired centrally in dispatchGsapOpAndPersist via an optional resolverTarget arg
(runs before its cutover gate); sdkGsapTweenPersist records inline before its
own leading gate (set/remove → animation parity, add → element parity).
deleteAllForSelector resolves by selector, not an id — left out.

Tests: animation_not_found on unresolved id / parity no-op on a real located id /
flag-off no-op. Full studio suite green; no circular dep.

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

* fix(studio): resolver shadow review fixes — divergence-only emit + restore in finally

Addresses PR #1547 review (Miga, Rames):

- #1 (medium): runResolverShadow emitted `sdk_resolver_shadow` on every edit,
  including parity (mismatchCount 0) — a PostHog event per style/text/attr edit
  at default-ON. Now emits only on divergence, matching recordResolverParity /
  recordAnimationResolverParity. Parity is silent across all three paths.
- restore() moved into a `finally` in sdkResolverShadowCheck: if checkOpValue
  throws between dispatch and restore, the patch listener no longer leaks and the
  shared session is always undone (the cutover-coupling failure mode this module
  guards against). dispatch errors still return dispatch_error.
- Comment on why batch is compatible with per-op inverse capture (a future SDK
  refactor that coalesces batch must keep emitting inverse patches).

Tests: A2 now forces a divergence to emit; A2b pins parity-is-silent; A4 covers
null/undefined hfId no-op. 26 shadow tests, full studio suite green.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 22:25:29 -07:00
Vance IngallsandMiguel Ángel ca1a8a6879 feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch) (#1462)
Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.

KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:27:03 -07:00
Vance IngallsandClaude Sonnet 4.6 ab7145ad9e feat(studio): stage 7 step 3c — sdk cutover for inline-style ops (#1522)
Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.

- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
  shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
  each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
  STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
  STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
  domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
  gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
  so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
  (new, 50 lines) — guard function + happy-path assertions.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:10:07 -07:00
James RussoandClaude Opus 4.8 29809069c8 feat(studio): storyboard markdown source editor (raw + live preview) (#1531)
Fourth PR in the Studio storyboarding stack. Adds an in-context way to view
and edit the storyboard's canonical files.

- Board | Source sub-toggle inside the storyboard view (StoryboardLoaded).
- StoryboardSourceEditor: raw CodeMirror markdown editor + live rendered
  preview (marked), with a file switcher for STORYBOARD.md and SCRIPT.md.
- Loads raw file text and saves via the existing files API
  (GET/PUT /projects/:id/files/*); on save the Board re-parses (reload), so
  markdown stays the single source of truth. Cmd/Ctrl+S to save.
- Deliberately raw, not WYSIWYG, so the structured frame fields can't be
  mangled.
- SourceEditor gains markdown language support (@codemirror/lang-markdown);
  adds the marked dependency for preview.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:59:40 -07:00
James RussoandClaude Opus 4.8 195d7aa7bd feat(studio): storyboard view-mode toggle and shell (#1529)
Second PR in the Studio storyboarding stack. Adds the top-level toggle
between the storyboard and the timeline/preview stage, behind the flag.

- STUDIO_STORYBOARD_ENABLED flag (VITE_STUDIO_ENABLE_STORYBOARD, default
  off) now gates the UI.
- ViewModeContext: timeline|storyboard state mirrored to the ?view= query
  param, so it survives reloads and an agent can deep-link ?view=storyboard.
- Segmented Storyboard|Preview control in StudioHeader (flag-gated).
- StudioApp swaps the whole center stage for a full-width StoryboardView
  when storyboard mode is active.
- useStoryboard hook + StoryboardView shell: global-direction header,
  loading/error/empty states. The frame contact-sheet grid lands in PR3.
- Extract StudioOverlays from App.tsx to stay within the 600-line studio
  decomposition budget.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:06:49 -07:00
Kiyeon Jeon 937ba2cebe fix(studio): add GSAP 3D inspector metadata (#1250) 2026-06-16 23:36:56 -07:00
ukimsanov 12955869ec refactor(studio): simplify color grading controls 2026-06-16 13:41:41 -07:00
ukimsanov 3661d51e6d refactor(studio): simplify color grading inspector 2026-06-16 13:41:41 -07:00
ukimsanov 8b92f37635 feat(studio): add color grading inspector controls 2026-06-16 13:41:41 -07:00
Miguel Ángel b9bd9ed91d fix: resolve computed GSAP timelines + drag improvements in Studio (#1506)
* feat(core): add param-substitution utility for GSAP timeline inlining

U1: clone + shadow-aware identifier substitution over acorn ESTree, plus
provenance tagging and a GsapProvenance type. Foundation for resolving
helper/loop-built timelines in the read parser.

* feat(core): inline helper-built and bounded-loop GSAP timelines

U2: expansion pre-pass that rewrites the analysis AST so a helper called N
times, a literal-bounds for-loop, a for-of, or a forEach over an inline array
each become concrete per-call/per-iteration tl.* statements with substituted
positions and provenance tags. Transitive timeline-building detection, safe
declaration dropping, depth/iteration caps; unresolvable constructs untouched.

* feat(core): resolve computed GSAP timelines in the read parser

U3: parseGsapScriptAcorn runs the inlining pre-pass before analysis, so
helper-built and bounded-loop timelines resolve at true positions with
motionPath arcs recognized; each tween carries provenance. Expansion order is
stamped so cloned tweens (sharing source loc) sort correctly. Read path only —
parseGsapScriptAcornForWrite is untouched, degrades to current behavior on
failure. The add-to-basket addCycle case now yields 7 resolved animations.

* feat(studio): runtime-authoritative keyframes for dynamic timelines

Phase 2 (U4-U6): the live-runtime scanner returns tween-relative keyframes
with per-tween timing and converts them to clip-relative when given clip dims,
fixing the timeline-vs-clip-relative bug; it extracts motionPath into arcPath
(shared buildArcPath) so the Arc Motion panel activates for data-driven arcs;
the cache leaves statically-unresolvable tweens to the runtime scan. Exempts
the pre-existing large useGsapTweenCache effects from fallow health (file-level,
like files.ts) rather than suppression comments.

* feat(studio): surface keyframe editability from provenance

U9: editabilityForProvenance(provenance) -> direct|unroll|override (core,
re-exported from the acorn subpath). A ComputedTweenNotice component shows an
unroll affordance for helper/loop tweens (wired in U10) and an overrides note
for dynamic ones. Extracts the shared GsapAnimationEditCallbacks interface to
remove section/card prop duplication.

* feat(core): lint understands computed timelines (acorn parser)

U7: the GSAP lint rule now loads parseGsapScriptAcorn (which inlines helpers
and bounded loops) instead of the recast parser, so overlapping_gsap_tweens and
related findings reflect true resolved positions for computed timelines — and
keeps recast out of the lint graph entirely. Literal compositions are
unchanged (parity), all 182 lint tests pass.

* docs: document the computed-timeline keyframe editing model

U8: keyframes.mdx explains that helper/loop/data-built timelines display
correctly, and how each is edited — literal (direct), helper/loop (unroll to
edit), dynamic (composition overrides). Nothing is permanently locked.

* feat: unroll computed timelines into literal tweens (U10)

Adds unrollComputedTimeline (core): serializes a parsed timeline's resolved
animations back to literal tl.* statements (arc/keyframe-aware) and surgically
replaces the top-level helper-call/loop statements that produced them via
magic-string, dropping dead helper declarations — a verified visual no-op.
Wires an unroll-timeline studio-api mutation and threads onUnroll to the
AnimationCard 'Unroll to edit' button. Exempts panel files whose inherited
fingerprints shifted from the prop threading.

* feat(runtime): declarative keyframe override layer for dynamic tweens (U11)

Adds applyKeyframeOverrides: fetches a gsap-overrides.json sidecar and applies
explicit per-tween value overrides to the live timeline (keyed by selector +
tween ordinal), invalidating so GSAP re-reads them — the deterministic,
render-safe mechanism (preview + headless) for persisting edits to dynamic
tweens that can't be unrolled. Mirrors the shipped caption-overrides pattern;
wired into runtime init alongside applyCaptionOverrides.

* refactor: drop the keyframe override layer; rely on unroll + source

Removes the gsap-overrides.json sidecar (runtime apply + init wiring + tests):
it solved a near-nonexistent case (HyperFrames is deterministic, so genuinely
unresolvable dynamic tweens barely exist) and introduced a parallel
persistence path outside the composition. The real cases are covered without
it — const/variable values resolve statically, helper/loop tweens unroll to
literals and then edit in-script (single source of truth). Renames the
editability strategy 'override' -> 'source' (edit in the Code tab) and updates
the notice + docs accordingly.

* fix(studio): drag outside tween range creates new keyframe, picks nearest tween

Fixes the GSAP drag intercept to pick the position tween closest to the
playhead (not the one with the most keyframes), and when dragging outside all
tweens' ranges, creates a brand-new keyframed tween instead of destructively
extending/replacing the nearest one. Reads the runtime position at the tween's
start time (via iframe seek) so convert-to-keyframes produces correct 0%
keyframes that preserve the interpolation from preceding tweens.

* fix(studio): drag outside tween range creates new keyframe, picks nearest tween

Also reverts all fallow health.ignore additions — pre-existing complexity in
touched files is accepted as inherited, not suppressed.
2026-06-16 13:14:31 -04:00
Miguel Ángel 8cbf4384e1 feat(studio): timeline inline expansion + __clipTree runtime primitive
When a child element inside a sub-composition is selected, the timeline
replaces the parent scene clip with the deepest-level siblings. Deselect
or selecting outside collapses back. Expanded clips are fully editable —
move, resize, delete, and split — addressed by their real DOM id with
timeline time rebased onto the sub-comp they live in.

Runtime:
- New window.__clipTree API: a read-only hierarchical ClipNode tree
  (id/parentId/children + backing element) so Studio can derive
  parent/child relationships for inline expansion.

Studio:
- useExpandedTimelineElements derives the expanded view from
  selectedElementId + clipParentMap (pure useMemo, no useEffect).
  Each child rebases onto its immediate sub-comp host (start +
  sourceFile), so multi-level nesting targets the right file.
- NLELayout routes expanded-clip edits through the same handlers
  top-level clips use, in local coordinates — edits save to the
  sub-comp source and reflect via reloadPreview (no separate DOM-patch
  path). This is the canonical update; there is no reactive observer.
- findMatchingTimelineElementId resolves sub-comp children with no
  top-level element to `sourceFile#id`.
- Razor tool enabled by default; studio_razor_split analytics event
  fired on single and split-all.
- O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a
  cached Set+WeakSet O(1) lookup.
2026-06-15 22:16:29 -04:00
Vance IngallsandClaude Opus 4.8 8f15e9f09b feat(studio): extend SDK shadow to delete/timing/gsap-add + default on (#1473)
* feat(studio): default SDK shadow dispatch on for parity telemetry

Shadow mode keeps the server patch path authoritative (no user-visible
change) and emits sdk_shadow_dispatch parity signal. Default it on so we
collect addressing/serialize-drift telemetry from all traffic before any
cutover. Disable via VITE_STUDIO_SDK_SHADOW_ENABLED=false.

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

* feat(studio): shadow parity for delete/timing/gsap ops + wire delete

Extends shadow visibility past the property-edit path. Adds a can()-first
shadow core (pure addressing/validity pre-check, works even for GSAP which
has no snapshot value) plus runShadowDelete/runShadowTiming/runShadowGsapTween.
Parity coverage: delete = getElement null (full); timing = snapshot
start/duration/trackIndex (full); gsap = can()+dispatch+returned-id only
(animationIds is a stub, tween values are script-level — full fidelity needs
serialize() round-trip diffing, out of scope).

Wires the delete runner end-to-end via an onElementDeleted callback
(useDomEditSession → useDomEditCommits → useElementLifecycleOps), fired after
the server delete succeeds. Server stays authoritative. Timing/GSAP wiring
follows (each needs threading sdkSession into useTimelineEditing /
useGsapScriptCommits).

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

* feat(studio): wire timing + GSAP-add shadow dispatch

Timing: thread sdkSession into useTimelineEditing; fire runShadowTiming after
move/resize persist (server authoritative). Moved the useSdkSession call above
useTimelineEditing so both share the single session (no duplicate).

GSAP: thread sdkSession through useGsapScriptCommits → useGsapAnimationOps;
shadow addGsapAnimation via runShadowGsapTween after the server add. Only the
add path is shadowed — delete/update key on the server's animationId, which
doesn't resolve in the SDK's independent id-space (would emit false
cannot_dispatch). "set" has no SDK method, so it's skipped.

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

* fix(studio): address #1473 review — no-persist shadow session + fallow gate

Blocker (Rames): the shadow runners dispatched on the live persisted SDK
session, so each shadow op fired the persist queue → an HTTP write of the SDK's
serialize() output, clobbering the studio's authoritative write (default-on
shipped this). Fix: open the shadow session WITHOUT persist — it reads from the
server but never writes back. Shadow dispatches mutate the in-memory model only
and are discarded on the next reload-on-change. Cutover (Step 3c+) must re-add
persist together with self-write suppression. No persist consumer exists in
this stack (cutover is not in main), so this is safe and keeps default-on.

Fallow CI gate (Miguel):
- drop unused `export` on RecordEditInput (dead-type)
- suppress pre-existing CRAP with reasons: commitMutation, addGsapAnimation;
  file-level complexity on useTimelineEditing (shadow .then() branches nudge
  several callbacks over threshold — telemetry-only)
- suppress 3 pre-existing clones surfaced by adjacent edits (save-error
  formatter, prop-drilling passthrough, file-change reload handler)

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

* fix(studio): scrub user content from shadow property-path telemetry

Addresses #1473 review concern (Rames): inline-style and text-content edits
put user content into the sdk_shadow_dispatch mismatch expected/actual fields.
Redact before emit — text-content values fully redacted (length only), others
length-capped at 64. The in-memory parity result keeps raw values, so the
parity logic and tests are unaffected.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:08:29 -07:00
69aa595f38 feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode (#1450)
* feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode

Wire onDomEditPersisted callback from useDomEditCommits into useDomEditSession,
calling reportShadowDispatch (flag-gated via VITE_STUDIO_SDK_SHADOW_ENABLED) to
dispatch equivalent SDK ops alongside the server patch path and emit
sdk_shadow_dispatch telemetry with mismatch details.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(studio/sdkShadow): catch dispatch errors, return dispatch_error mismatch

Wrap the dispatch loop in try/catch so a throwing SDK dispatch never
propagates to Studio UX. Returns dispatched:false with kind="dispatch_error"
and the error message for telemetry. One new TDD test (RED→GREEN verified).

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

* fix(studio): batch shadow dispatch, rename runShadowDispatch, add PatchOperation import

Wrap the shadow dispatch loop in session.batch() so a mid-loop throw
cannot leave the SDK session in a partially-applied state. Without the
batch boundary, one failing op would update some elements but not
others, diverging the shadow session from the real one.

Rename reportShadowDispatch → runShadowDispatch to eliminate the
misleading 'report' prefix — the function mutates the SDK session, it
is not read-only. Update the only caller (useDomEditSession).

Add missing PatchOperation import to useDomEditCommits (the type was
already used in the onDomEditPersisted interface but never imported).

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

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* docs(studio/sdkShadow): note persist:error drift risk in parity comparisons

Also remove unused re-exports from useDomEditCommits (GSAP_CSS_FALLBACK_BLOCKED_MESSAGE
and PersistDomEditOperations — fallow confirmed 0 consumers) and suppress the
Vite ?raw import in sdk-playground that fallow can't resolve statically.

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

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 15:38:35 -07:00
Miguel Ángel 11b050de9a feat(studio): scale GSAP positions on clip resize + shift on drag + diamond fixes (#1448)
Resize: proportionally scale all GSAP animation positions and durations
to fit the new clip duration via scalePositionsInScript. This preserves
clip-relative keyframe percentages — diamonds don't move during resize,
nothing disappears. Modeled after After Effects Time Stretch behavior.

Drag: shift all GSAP positions by the time delta (unchanged from before).

Diamond rendering:
- Clamp diamonds at 0%/100% so they stay fully visible at clip edges
- Filter out-of-range keyframes using predicted percentages during resize
- Clamp connection lines to clip boundaries
- PropertyRows: same edge clamping for SVG diamonds

Parser: scalePositionsInScript (proportional position + duration scaling),
shiftPositionsInScript (rigid shift), scale-positions + shift-positions
mutation types, 5 shift tests passing.
2026-06-15 02:31:35 -04:00
e6da47d8f8 feat(studio): drag keyframes with live beat snapping (#1439)
* feat(studio): drag keyframes with beat snapping

Keyframe diamonds are draggable with live preview and snap to the music
beat grid (requires VITE_STUDIO_ENABLE_KEYFRAMES=1).

Drag model: a tween start point trims the front (end fixed), an end point
resizes (start fixed), an intermediate keyframe moves within the tween
(adjacent segments resize, others untouched; start/end moves remap the
intermediates to preserve their absolute times). The keyframe snaps to the
nearest beat within ~8px, centered exactly on the dot.

Reliability: the commit resolves the dragged element's selection + parsed
animations on demand (awaited) instead of relying on the async DOM-edit
session, picks the tween whose window contains the keyframe's original
time among same-group tweens, and holds the dropped position optimistically
until the cache round-trip lands. Cache clip% precision raised to 0.001%
so the marker lands exactly where dropped.

Pure match/plan logic + unit tests in editor/keyframeMove.ts.

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

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): harden keyframe drag commit (review follow-ups)

- pickKeyframeTween no longer falls back to ALL animations on a selector
  mismatch — it only picks among the dragged element's own tweens, so a
  class/compound-selector mismatch can't edit a different element. No
  match → no-op.
- computeKeyframeMovePlan bails to a no-op when a keyframe-array tween's
  dragged keyframe can't be located (stale cache / precision drift) instead
  of falling through to an end-point resize that silently rescaled the
  whole tween and re-timed every keyframe.
- usePopulateKeyframeCacheForFile clipPct now uses 0.001% precision
  (matching useGsapAnimationsForElement) so beat-snapped keyframes from the
  file-wide cache also center on the dot and the two caches agree.
- The optimistic drag hold only releases once the cache reflects the
  committed position (a keyframe near the held %), so an unrelated cache
  rebuild no longer flashes the diamond back to its old spot.
- A drag's document listeners are cleaned up on unmount, so an unmount
  mid-drag (clip delete / comp switch / zoom-out) no longer leaks them.

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

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): shrink + lower keyframe diamonds under the beat strip

When a clip's track shows the beat-dot strip (the top band), its keyframe
diamonds and connecting lines render at 45% size and centered in the
region below the band, so they don't collide with the dots. Full size and
vertically centered otherwise.

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

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): ignore keyframe re-drag during the optimistic-hold window

After a drop, the diamond is held at its dropped position (via effPct) until the
file round-trip lands, but `pct` passed to handlePointerDown still comes from
props (the pre-drop position). Re-grabbing the same keyframe in that window
would track the drag from a stale origin and commit against the wrong tween (or
no-op via the stale-cache guard). Skip starting a drag while a hold is pending;
it clears on the cache match (≤2s fallback). Click selection is unaffected.

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

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-14 17:23:54 -07:00
Carlos Alcaraz Gregor 5c8b637369 fix(studio): route rotation field edits through the animation like X/Y/W/H (#1427) 2026-06-14 13:12:43 -04:00
Miguel Ángel a241f2591e fix(studio): break all 7 circular dependency cycles and fix rules-of-hooks violation (#1422) 2026-06-13 19:23:05 -04:00
Miguel ÁngelandMiguel Ángel 7bff49ecf0 refactor(studio): simplify hooks, split contexts, remove dead code (#1416)
* fix(studio): guard Zustand no-op setters and fix useConsoleErrorCapture memory leak

- Guard setIsPlaying to skip set() when value unchanged (eliminates 60
  notifications/sec during reverse playback)
- Guard caption store selectGroup to bail before set() when group missing
  (prevents empty Zustand notifications)
- Guard clearSelection to skip when already empty
- Fix useConsoleErrorCapture: restore original console.error, remove error
  event listener, and delete __hfErrorCapture flag on cleanup

* fix(studio): delete dead files and unused exports

Remove 7 dead files (audioBeatDetection, keyframeSnapping,
timelineInspector, DopesheetStrip, StaggerControls,
TimelineLayerPanel, TimelineEditorNotice) and their test companions.

Delete unused computeFitToChildrenSize export from propertyPanelHelpers.

Fix re-export indirection: useDomEditCommits and studioMotionOps.test
now import patch builders directly from manualEditsDomPatches instead
of the re-export passthrough in manualEditsDom.

* fix(studio): eliminate effect-chain state mirroring for lint findings, hover, and GSAP fetch

Move lint findingsByElement sync from App.tsx into useLintModal where
the value is produced, removing the mirroring useEffect. Consolidate
4 hover-clearing effects in useDomSelection into 2 (one unconditional
on context change, one conditional combining caption mode, selection
match, and disconnected element checks). Fold the GSAP retry effect
into the fetch effect in useGsapTweenCache, scheduling a single retry
via setTimeout when the initial fetch returns 0 animations.

Eliminates 3 unnecessary render cycles from effect chains.

* fix(studio): memoize renderQueue, toolbar, and canvas rect to prevent re-render cascade

- Wrap renderQueue object in useMemo so StudioContext consumers don't
  re-render on every App render
- Memoize timelineToolbar JSX so NLELayout memo isn't defeated
- Move canvasRect getBoundingClientRect() from render-time IIFE to a
  useLayoutEffect-backed ref, eliminating layout thrashing
- Track and clear setTimeout handles in refreshPreviewDocumentVersion
  to prevent stale timer accumulation on rapid calls and unmount

* refactor(studio): consolidate GSAP shared primitives — defaults, iframe access, keyframe parsing

Extract duplicated PROPERTY_DEFAULTS, IframeGsap interface, iframe
accessors (getIframeGsap, queryIframeElement), percentage keyframe
parsing, and toAbsoluteTime into a single gsapShared.ts module.

Removes ~120 lines of copy-pasted logic across 8 hook files, reducing
drift risk between the duplicate implementations.

* fix(studio): remove dead store fields, dead file, duplicate helper, and unsafe assertions

* refactor(studio): deduplicate selector helpers, rounding utils, percentage computation, and iframe access

* fix(studio): split StudioContext into Shell + Playback to prevent cascade re-renders

* refactor(studio): decompose useGsapScriptCommits into focused mutation hooks

* refactor(studio): decompose useFileManager into focused file operation hooks

Extract useFileTree (tree loading, refresh, derived assets/compositions)
and useEditorSave (debounced save with history tracking) from the 508-LOC
useFileManager. The parent hook composes both and retains file I/O,
click-to-source, upload/import, and CRUD — preserving the same public
interface so no consumers change.

* refactor(studio): decompose useDomEditCommits into focused commit hooks

Extract geometry (path offset, box size, rotation) and element lifecycle
(delete, z-index reorder) into useDomGeometryCommits and
useElementLifecycleOps. Parent keeps persistDomEditOperations as core
and composes all sub-hooks — public interface unchanged.

* refactor(studio): simplify useAppHotkeys with declarative command table

* refactor(studio): simplify useAppHotkeys with declarative command table

Replace 15 individual useRef callback refs with a single cbRef object.
Extract keydown dispatch into pure dispatchModifierKey/dispatchPlainKey
functions. Merge duplicate undo/redo logic into shared applyHistory.
Extract cross-origin listener boilerplate into safeAddListener/safeRemoveListener.

Hook body: 204 LOC (down from 445). Public API unchanged.

* fix(studio): remove unused getDomEditTargetKey import

* refactor(studio): decompose useDomEditSession into focused editing hooks

Extract GSAP-aware geometry intercepts (move/resize/rotation) and
animated property commit into useGsapAwareEditing, and selection
wiring, GSAP cache management, preview sync, and selection handlers
into useDomEditWiring. The parent remains a pure composition shell.

* style(studio): fix formatting in 5 files

* fix(studio): trim App.tsx to 598 lines (under 600 limit)

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-13 18:25:11 -04:00
Carlos Alcaraz Gregor 3c5607063f fix(studio): disable the rotation field when the element can't be rotated (#1412) 2026-06-13 11:02:36 -04:00
Miguel Ángel 5b6c62e151 fix(studio): surface gesture recording controls (#1390) 2026-06-12 23:16:41 -04:00
Miguel Ángel 1ea0ed55e3 fix(studio): stabilize manual drag targets (#1393)
## Problem

Studio manual drag had two bad target paths in `hf-keyframes-test`:

- Drag could start from a cached hover selection instead of the selected overlay, so moving `#cta` could write an offset to the wrong element while the overlay appeared correct.
- `#stage` is the visual 1920x1080 canvas, but the composition metadata lives on `<html data-composition-id ...>`. Studio treated `#stage` as a normal movable layer, so dragging it moved the coordinate basis the overlay depends on and left Undo with no reliable saved edit to reverse.

## Fix

- Manual drag now starts only from the selected overlay bounds; canvas pointer-down no longer starts movement from `hoverSelectionRef`.
- Added a structural root-layer heuristic: a direct body child matching the composition root's declared `data-width` / `data-height` is selectable and style-editable, but cannot receive manual offset/size/rotation edits.
- Manual geometry commits now return/rethrow the save promise so failed writes roll back optimistic DOM changes instead of silently sticking.
- GSAP-targeted CSS fallback edits are blocked before applying manual geometry when the GSAP drag intercept is unavailable.

## Tests

- New regression coverage for stale-hover drag start and full-canvas root-stage capability resolution.
- Focused DOM-edit suite: 119/119 pass.
- Full Studio suite: 800 pass, 18 todo.
- Studio typecheck clean; oxlint/oxfmt clean; lefthook pre-commit clean.
- Studio build passes with the existing Vite chunk-size warning.
- Browser verified with `agent-browser`: attempted `#stage` drag writes no offset and keeps Undo disabled; `#cta` drag writes exactly one offset to `#cta`; Undo returns the fixture to zero offsets.

Local browser artifacts: `artifacts/studio-cta-drag/stage-cta-drag-fix.webm`, `artifacts/studio-cta-drag/stage-cta-drag-fix-final.png`.
2026-06-12 19:16:36 -04:00
Miguel ÁngelandMiguel Ángel d0a7f7d839 fix(studio): remove motion tab from right panel (#1391)
The motion panel was behind a feature flag defaulting to false and never
shipped. Remove the tab button, MotionPanel component, feature flag,
and all associated wiring (EaseCurveEditor, SpringEaseEditor,
MotionPanelFields, MotionPathOverlay). The underlying motion data
infrastructure (studioMotion, studioMotionOps) remains intact for the
GSAP panel.

Co-authored-by: Miguel Ángel <miguelangelsisi098@gmail.com>
2026-06-12 16:37:05 -04:00
Miguel Ángel 9e5b3d644f fix(studio): remove unused onSelectElementById after indicator removal (#1377) 2026-06-12 11:34:16 -04:00
Miguel Ángel 3ba01129e7 fix(studio): remove off-screen element indicators for UX redesign (#1376) 2026-06-12 11:23:18 -04:00
Miguel Ángel c3554dcffe fix(studio): disable keyframes feature flag by default, release v0.6.93 2026-06-12 00:59:17 -04:00
Miguel Ángel 6babf5be7d fix(studio): update drag intercept test for true default 2026-06-12 00:32:01 -04:00
Miguel Ángel a433d83fec fix(studio): trim PropertyPanel to 600-line limit 2026-06-12 00:29:36 -04:00
Miguel Ángel d49ee416a3 fix(studio): gesture recording replaces existing position keyframes (#1360)
Gesture recording uses replace-with-keyframes mutation to replace existing
position-group tween. Fix N1 sign inversion and N9 wheel startPointer
with pointerElementOffset subtraction.
2026-06-12 00:20:08 -04:00
Miguel Ángel c435a4ee46 fix(studio): property panel group-aware keyframe routing (#1358)
* fix(core): per-property-group keyframe foundations

Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.

* fix(core): add split-into-property-groups and replace-with-keyframes mutations

Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.

* fix(studio): per-property-group intercept routing + drag/resize fixes

Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.

* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes

Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.

* fix(studio): property panel group-aware keyframe routing

Add animIdForProp helper routing keyframe diamonds to correct property-group
animation. Wire StudioPreviewArea delete/move/toggle handlers to use
propertyGroup for routing. Fix per-property epsilon in rdpSimplify.
2026-06-12 00:19:31 -04:00
Miguel Ángel 95029e083e fix(studio): per-property-group intercept routing + drag/resize fixes (#1356)
* fix(core): per-property-group keyframe foundations

Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.

* fix(core): add split-into-property-groups and replace-with-keyframes mutations

Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.

* fix(studio): per-property-group intercept routing + drag/resize fixes

Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
2026-06-12 00:19:13 -04:00
Vance IngallsandClaude Fable 5 fc3ab76ce8 fix(studio,core): persist manual position edits for GSAP-owned elements (#1346)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)

* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access

- index.ts no longer exports document/session/history/persist-queue (those
  modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
  noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
  arrive in #1325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline

- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
  parser-backed ops instead of silently no-opping — callers must never
  believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
  dist-only; top-level build previously produced no SDK artifacts).
  publish.yml intentionally NOT updated — sdk stays unpublished until
  Phase 3 completes.

Adversarial-review findings F3 + F4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs

Round-2 review (Rames/Miguel) on the engine layer:

- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
  ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
  survive postMessage/structured-clone, which T3 embedded hosts may forward
  patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
  as a forced override of inline style (init.ts applyCompositionSizing).
  Style is always written; the data-* attr is updated when already present
  so the edit isn't clobbered on load. Absent attrs stay absent — inverses
  stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
  (add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
  live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.

NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): moveElement writes data-x/data-y, not left/top CSS

HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.

Adds inverse round-trip test for prior position restore.

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

* chore: update bun.lock after sdk package registration

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

* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete

* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): restore full public exports now session/document modules exist

index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): coalesce history by patch paths; replay override-set on open

Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting

* fix(studio,core): persist manual position edits for GSAP-owned elements

- sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom
  properties and transform longhands via setProperty; patch the style
  attribute string directly so --hf-studio-offset-* and translate survive
  the server round-trip (positions never reached disk before this)
- gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens
  ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS
  translate into its cache once at init, zeroes the longhand once, and never
  re-reads it
- applyStudioPathOffset: for GSAP-owned elements keep translate:none live and
  sync the offset into GSAP's cache via gsap.set; writing the longhand
  double-applied the offset (disappearing elements, scrub snap-back)
- buildPathOffsetPatches: emit the var() translate expression explicitly so
  the persisted file re-folds on reload (live inline is none)
- StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response
  probe mutates GSAP's cache, which inline-style restore cannot undo (click
  made elements jump by the probe distance)
- reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop
  seek-time double-apply
- STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is
  opt-in until its recording path is hardened; commits take the CSS persist
  path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio): remove duplicate flag declaration, trim useDomEditCommits to 600 lines

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:30:14 -07:00
Miguel Ángel a5bc52bad1 fix(studio): add GSAP drag intercept feature flag, disabled by default (#1341)
Add VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT env var (default: false) to
gate the GSAP drag/resize/rotation intercept in useDomEditSession. When
off, dragging GSAP elements falls through to the standard CSS path
instead of committing via script mutation.

Manual dragging (STUDIO_PREVIEW_MANUAL_EDITING_ENABLED) remains on.
2026-06-11 02:08:32 -04:00
Miguel Ángel ef18613975 feat(studio): razor/blade tool UI for timeline clip splitting (#1331)
Wire the razor tool into Studio's timeline UI:

- B enters razor mode (crosshair cursor + red vertical guide line)
- Click any clip to split at the click position
- Shift+click splits all clips across every track at that time
- V or Escape exits razor mode
- Toolbar shows selection arrow / scissors toggle

Add useRazorSplit hook for split orchestration (HTML + GSAP mutation).
Add activeTool state to playerStore. Add preview reload after timeline
move/resize operations so the composition re-renders with updated timing.
2026-06-10 23:56:09 -04:00
Miguel Ángel f0b499b582 fix(studio): keyframe bug fixes — gate delete hooks, fix value corruption, gesture recording (#1314)
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
  stripStudioEdits flag on the delete mutation type so they only fire on
  user-initiated deletes, not on internal delete-then-recreate drags.

- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
  elements with CSS opacity:0 stay visible after collapsing keyframes.

- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
  precision for visual properties (opacity, scale, rotation) instead of
  Math.round which corrupted mid-fade values to 0.

- Guard VISUAL_BASELINE against cross-tween contamination by querying
  __timelines for properties animated by other tweens on the same element.

- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
  containing opacity, guard against relative values (+=/-=/*=), and add
  Number.isFinite check.

- Fix falsy-zero doubling in drag commit: replace || fallback with
  Number.isFinite so a base GSAP position of 0 is correctly preserved.

- Fix gesture recording sign inversion: remove pointerElementOffset
  subtraction from dx/dy formula and instead apply it once to basePosition
  so the element center tracks the pointer.

- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).

- Strip all diagnostic logs from production code.
2026-06-10 18:53:06 -04:00
8fcbb63a37 docs(readme): swap hero media to hyperframes-logo-motion (#1315)
* docs(readme): swap hero media to hyperframes-logo-motion

Replaces the prior hfgif-1280.webp hero with a new logo-motion clip
Bin trimmed for the launch. Converted the source MP4 to animated webp
(the existing hero's format) so it auto-plays in the GitHub README the
same way the old one did - MP4 sources don't render inline or autoplay
in <img> tags.

- New asset: static.heygen.ai/hyperframes-oss/docs/images/
  hyperframes-logo-motion-1280.webp (1280x720, 85 frames, 199KB)
- ffmpeg conversion: scale=1280, libwebp_anim, q=80, loop=0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(studio): format 5 hooks files (oxfmt)

* style: remove unused imports in studio hooks (pre-existing lint failures)

CI Lint on main was already failing with 5 unused-import errors in
packages/studio/src/hooks/. Removed the unused symbols to unblock the
README hero PR's CI:

- gsapRuntimeBridge.ts: resolveTweenStart, resolveTweenDuration
- useGsapScriptCommits.ts: usePlayerStore
- useTimelineEditing.ts: PatchTarget (type-only)
- gsapDragCommit.ts: readGsapProperty

Bundled into the README PR per James's request to fix CI in-place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(studio): add childRects: [] to DomEditOverlay test mock

useDomEditOverlayRects' return type added a childRects: OverlayRect[]
field; the DomEditOverlay test's mock didn't get updated and was
returning an object without it, so DomEditOverlay.tsx's
'childRects.length > 0' check threw TypeError on undefined.

One-line mock-vs-hook contract realignment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(studio): drive player-store currentTime in selection-hydration test (#1311 follow-up)

The 'hydrates seek first, preserves the initial url state, then restores
selection' test was failing because PR #1311 (keyframes feat) changed
useStudioUrlState to read currentTime from the player store via
usePlayerStore((s) => s.currentTime), removing it from the hook's prop
shape. The test was still trying to drive currentTime via the harness
prop, which is now a no-op — so the selection-hydration useEffect's
time-stability guard

  Math.abs(currentTime - stableTimeRef.current!) > 0.05

never passed (store currentTime stayed at 0 while stableTimeRef caught
the 4.2 seek target). buildDomSelectionFromTarget was never reached,
applyDomSelection was never called, and the assertion got 0 calls.

Fix: setState the store's currentTime to 4.2 ahead of the rerender so
the hook's selector picks it up and the time-stability guard passes.
Harness prop kept as-is — it's a no-op but doesn't hurt.

Pre-existing failure on main HEAD 81416ab3; surfaced as CI gate on the
unrelated docs/readme-hero-motion-update PR.

* test(studio): stub getBoundingClientRect + flush RAF in DomEditOverlay test

The 'renders selected bounds right after clicking a movable selection'
test asserts the selection box appears after pointerdown, but happy-dom
returns 0 for newly-created elements' getBoundingClientRect. The
overlay's compRect updates via a RAF loop that early-returns when iframe
width is 0; the keyframes PR a468550f added a compRect.width > 0 guard
to the selection-box render path, so compRect=0 silently gates the box
off and the assertion fails.

Stub Element.prototype.getBoundingClientRect to return 800x450 for the
test, and flush two RAFs after render so the compRect state update lands
before the pointerdown assertion. Restore the prototype at test end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Miguel Sierra <miguel.sierra@heygen.com>
2026-06-09 23:39:22 -07:00
Miguel Ángel 81416ab3c9 refactor(studio): split oversized files to pass 600-line check (#1313) 2026-06-09 19:53:14 -04:00
Miguel Ángel a468550f82 feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)
* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): keyframe cache + commit hooks

Add hooks for keyframe cache population (tween → clip-relative %),
mutation dispatch, keyframe snapping, and audio beat detection.

* feat(studio): timeline UI — dopesheet diamonds + keyboard nav

Add dopesheet strip with diamond keyframe indicators, timeline property
rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate
(STUDIO_KEYFRAMES_ENABLED defaults to false).

* feat(studio): design panel — arc controls + ease curve + stagger

Add arc path controls (curviness slider, auto-rotate), motion path SVG
overlay, ease curve visualization, stagger controls, and expanded
animation card. Includes border-radius editor dependency from #1217.

* feat(studio): gesture recording core

Add gesture recording engine with RAF sampling, modifier key property
mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity),
Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay.

* fix(studio): keyframe drag + recording bug bash

21 fixes: capture GSAP base at drag start, translate:none before
gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording
seek, _auto flag for 100% keyframes, overlay flash fix, block edits
during recording.

* feat(studio): keyframe integration wiring + docs

Wire App.tsx recording orchestration, TimelineToolbar K/R buttons,
PropertyPanel per-property diamonds, shortcuts panel, toast
notifications, and keyframes guide documentation. All gated on
STUDIO_KEYFRAMES_ENABLED (default false).
2026-06-09 18:30:23 -04:00
Vance IngallsandClaude Sonnet 4.6 0923bc0787 feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) (#1299)
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b)

* refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review)

- Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined`
  normalisation; guards against empty-string data-hf-id reaching findTagByTarget
- Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site
  that still used `?? undefined` instead of `|| undefined`)
- Re-export readHfId through domEditing.ts public API
- Add readHfId unit tests: present, absent, empty-string, whitespace-only
- Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only
- Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports
  brought into fallow scope by the T5b hfId changes)

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

* fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review)

cloneNode(true) copies all attributes including data-hf-id. Without clearing it,
both halves of a split share the same hf-id; the server's findByHfId picks the first
match and silently patches the wrong clip. Remove the attribute from the clone so
write-back re-mints a fresh id on the next preview load.

Adds a test: splitElementInHtml — hfId clone isolation.

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

* fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review)

- Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item
  construction in collectDomEditLayerItems compiles
- Widen getDomLayerPatchTarget return type to include hfId + populate it from
  data-hf-id attribute (domEditingElement.ts)
- Widen findDomEditSelectionTarget to check hfId-first when no id/selector
- Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts
- Add hfId to buildMissingCompositionElements element construction
- Add hfId-targeted test coverage in domEditing.test.ts,
  domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts
- Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:15:27 -07:00
Vance Ingalls 82c754f27a feat(studio): wire hfId through DOM-edit patch targets, activate hfId lookup path (R7, T5a) (#1297) 2026-06-09 14:19:46 -07:00
Vance Ingalls 2071c85c9a feat(studio,core): populate hfId in DomEditSelection + widen MutationTarget (R7, T5a) (#1296)
## Summary

- Adds `hfId` field to `resolveDomEditSelection` — reads `data-hf-id` off the live element and stores it in `DomEditSelection.hfId`
- `DomEditSelection extends PatchTarget` which already declares `hfId?: string`, so this is a single new line at the return site
- Widens `MutationTarget` in `files.ts` to include `hfId?: string` (type hygiene — the value already survives through `parseMutationBody`'s by-reference pass, so this is documentation not a behaviour change)

## Why

R7 / Task 5a. The full hf-id write-back and patch-engine infrastructure (R1 + R7 Tasks 0–4, PRs #1269–#1292) is server-complete. The only missing piece was: the Studio client never read `data-hf-id` off a hit-tested element, so `target.hfId` was always `undefined` and the `hfId`-first lookup branches in both patch engines were unreachable in production. This PR fixes the selection side — the commit wire (#1297) completes the path.

## Test plan

- [ ] `packages/studio/src/components/editor/domEditingLayers.test.ts` — two new tests with jsdom environment:
  - `resolveDomEditSelection` on an element with `data-hf-id` → `selection.hfId` is populated
  - element without `data-hf-id` → `selection.hfId` is `undefined`
- [ ] All 65 studio test files pass, all 72 core test files pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 14:18:14 -07:00
Vance IngallsandClaude Sonnet 4.6 3d7d7c0291 refactor(core,studio): extract draft-marker constants + R7 code-review fixes (Task 4) (#1292)
* refactor(core,studio): extract draft-marker constants to core (R7, Task 4)

Create draftMarkers.ts in core with 5 shared CSS custom property names and the
gesture DOM attribute. PreviewAdapter imports from draftMarkers.ts instead of
hardcoding strings. Adds @hyperframes/core/studio-api/draft-markers export
subpath. Studio's manualEditsTypes.ts re-exports the shared constants from core
so all existing call sites are unchanged.

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

* fix(core): address R7 code-review findings (C1–C14, P6–P7)

- previewAdapter: auto-revert previous gesture in applyDraft (C3); clearDraftProps
  on commitPreview not just revertDraft (C4); isVisible NaN→visible for JSDOM (P7);
  remove redundant GestureState.hfId field (C12); remove Array.from (C14);
  extract clearDraftProps/revertGesture helpers (C5/C6)
- hfIdPersist: replace string-equality change detection with attribute count to
  avoid false-positive writes on single-quoted HTML (C1); re-read disk before
  write for TOCTOU guard (C7); remove normalizeHfIds wrapper (C11)
- preview.ts: remove dead null-check on normalizedDisk after diskMain guard (C9);
  catch path re-reads disk fresh instead of using stale pre-request snapshot (C8)
- hfIds.test.ts: replace tautological second stability test with cross-document
  content-keyed id stability test (P6)

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

* fix(core): follow-up R7 review fixes — CSS.escape fallback, invariant docs, new edge-case tests

- hfIdPersist: remove ensureHfIds re-export (P2); add JSDoc invariant note;
  improve TOCTOU comment; pass err to console.warn
- preview.ts: split import — ensureHfIds from parsers/hfIds.js (not re-export)
- previewAdapter: CSS.escape + inline fallback for non-browser environments;
  add JSDoc for atTime caller-seek contract; add 0.01 opacity-threshold comment
- previewAdapter.test: rename atTime test to clarify adapter-does-not-seek;
  add nested-hf-root-without-id test; add resize→move prop-leak test;
  add revertDraft-after-commit no-op test

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

* test(core): bundle-vs-disk id-stability test; comment double ensureHfIds (P3)

- preview.test: add "bundle returning untagged HTML gets same ids as disk" test —
  guards against id divergence when bundler reads a pre-write cache snapshot;
  content-keyed FNV1a minting ensures served ids == disk ids for same source HTML
- preview.ts: comment the second ensureHfIds call explaining it's intentional for
  adapter-injected elements and idempotent on the no-bundle path (P3 from miguel)

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

* docs(core): wire-contract comment on mintHfId + fallow suppressions (R7)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 01:13:54 -07:00
Vance Ingalls 5b29bea0b1 test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization (#1259)
Fixes four gaps identified in max-setting code review:
- Box-size clear: replace arrayContaining with full ordered toEqual (30 ops)
- Box-size / pathOffset / rotation clear: add empty-string coercion tests
  (origVal||null must produce null, not set property to "")
- Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR
- Motion clear: prove input-independence by calling with both empty and populated
  element and asserting identical output
2026-06-07 19:20:42 -07:00
Vance Ingalls 63ff0c16c3 test(studio): add T5b rotation+motion build-patches characterization (#1258)
Extends manualEditsDomPatches.test.ts with rotation and motion pairs.
Same 4-pattern structure: populated, empty, clear restores originals,
build/clear symmetry. Merges duplicate manualEditsTypes import block.
2026-06-07 19:15:25 -07:00
Vance Ingalls 19f46cd19a test(studio): add T5a pathOffset+boxSize build-patches characterization (#1257)
## Summary

T5 (part 1 of 3) — characterization test suite for `manualEditsDomPatches.ts`.

The source module exports 8 functions (4 build/clear pairs) that write and restore draft-marker attributes and inline styles onto iframe elements before source-patch operations. It had zero tests.

This PR covers the **pathOffset** and **boxSize** pairs with 4 patterns each:

- **populated** — fully-configured element produces exact expected `PatchOperation[]` in declaration order
- **empty** — bare element yields only the mandatory marker attribute op
- **clear restores originals** — `buildClear*` reads `STUDIO_ORIGINAL_*` attrs and produces correct restore values
- **build/clear symmetry** — every `{type, property}` key that `build*` can emit is also addressed by `buildClear*`; an orphan here means a property stranded in committed source HTML

Uses `@vitest-environment happy-dom` matching the Studio package convention. Element setup via `document.createElement` + `style.setProperty` / `setAttribute`.

## Stack

- **#1257** (this PR) — pathOffset + boxSize pairs
- **#1258** — rotation + motion pairs
- **#1259** — review-fix gaps (ordered clear assertion, coercion paths, edge cases)
2026-06-07 19:07:59 -07:00