Commit Graph
5 Commits
Author SHA1 Message Date
Miguel Ángel 17ac986bfe fix(studio): canvas selection, drag and resize correctness (#3146)
* fix(studio): size the selection box by the transform the element actually paints under

The box around a text layer inside the playground card stopped mid-word. The
layer is 260px wide and paints 313, because its parent carries `scale(1.2)`,
and the chrome read only the element's OWN transform. The top-left looked
right, since the corners are anchored to the real bounding rect, so only the
right and bottom edges fell short, by exactly 1/1.2.

The same read decides whether to draw the box rotated at all, so an element
whose parent is rotated got an upright box over a rotated one.

The transform is now accumulated from the element up to the composition root.
Only the linear part matters: each transform's origin contributes translation,
and translation is already discarded by matching the corners to the element's
bounding rect, so composing the matrices is enough and no per-ancestor origin
has to be unpicked. The walk stops inside the composition document, because the
canvas zoom lives on the iframe in Studio's own document and is applied
separately.

The fake DOMMatrix the geometry tests use gained the `multiply` it now needs.

* fix(studio): drag by the movement the element actually makes, not the one assumed

An element that had never been dragged skipped the movement measurement and took
the canvas zoom as the whole screen mapping. Nothing above the element was
considered, so any parent transform broke the drag: a card at rotationY 180 with
scale 1.2 maps a rightward drag to -1.2x the zoom, meaning the text walked LEFT
while the overlay followed the pointer, and the overlay only snapped onto the
text at drop, when it re-measured.

Measured on the live element in that card: one unit of drag offset moved it
-0.757 px on x and +0.757 on y, where the skipped path assumed +0.631 on both.

The measurement it skipped already handles this — it moves the element, watches
where it lands, and inverts that, which is right for rotation, mirroring, scale
and perspective alike. So the special case is gone and every drag measures. Same
element after: a 120x80 pointer drag moves it 120.3x80.2.

Rewrote the test that asserted the skipped path's identity matrix for an
unmovable element. It now asserts the honest outcome: an element with no
measurable movement is reported unmeasurable whether or not it carries a path
offset, and the caller's existing fallback covers it.

* fix(studio): shift-click adds the element under the pointer, not the last one hovered

Shift-click read the hover cache and used it without checking what it described.
That cache is filled asynchronously as the pointer moves, so passing over one
element on the way to another leaves it naming the element you left. The
shift-click then added THAT element, and because the same branch prevented the
default and set the suppression flags, the mousedown path that would have
resolved the point correctly never ran. Multi-select looked like it grabbed
things at random, or like it did nothing.

Reproduced on the canvas with a trace: hover #card, shift-click #dot-b, and the
group gained #card. Same gesture after: the guard rejects the cache, the
mousedown path resolves the point, and the group gains #dot-b.

The cache is still used when it is provably about the point clicked, including
when it names a clip ancestor of the element there, so the fast path survives for
the common case of clicking straight at something.

Adds `hf-select-debug` (localStorage, off by default) recording which selection
branch ran and what it decided, and pulls the flag/format shared with
`hf-reload-debug` into one place rather than copying it.

* fix(studio): keep every element a marquee caught, not just the first

The marquee built the group correctly and then threw it away. It announced only
the primary to the timeline, and the timeline is the source of truth for what is
selected: the sync back to the canvas saw one selected id against a group of
several, decided the canvas was stale, and replaced the group with that single
element a moment after the drop. Drag a box around four things, get one.

The whole set is announced now, and the primary goes in as its anchor rather
than as a new single selection, so the set it just joined survives. This is the
same reason the single-select path already anchors with preserveSet.

A test drives applyMarqueeSelection with two elements and asserts both reach the
timeline; it fails against the old single-id announce.

* fix(studio): stop a group selection from erasing itself on the timeline

Every canvas selection is mirrored onto the timeline, and the timeline syncs
back — whatever it holds replaces the canvas selection a moment later. The
mirror announced only the primary and anchored it with preserveSet, but
preserving a set that does not contain the id empties the set, and an empty set
syncs back as "nothing is selected". Adding a second element, or re-resolving a
group after moving it, could therefore drop the whole selection rather than keep
it.

One helper now owns the mirror: publish the members, then anchor. A single
selection keeps the previous contract deliberately, so a late async primary
still cannot collapse a live group and a fresh click still collapses a stale
one. The group re-resolve path also gains the ancestor id fallback the other
callers already had — without it a member with no direct timeline row resolved
to null and deselected everything.

Two tests: a second element joining a selection, and a marquee, both assert the
full set reaches the timeline. Both fail against the announce-the-primary-only
version.

* chore(studio): trace what moves a dragged group and when

A drag that jumps is a position that changed without the pointer asking for it,
and nothing on that path says anything today, so the frame it diverges can only
be guessed at. `hf-drag-debug` (localStorage, off by default) records the whole
gesture: the mapping and start position each member got, the pointer delta
against the delta actually applied on every eighth move, what each member was
told to commit, and where they all sit at the drop, once the commit resolves, and
120/400/900ms later.

That last group is the point of it. The source write, the preview reload and the
timeline resume all land within a few frames of the drop, and any of them can put
the elements back where they started before the new position arrives — a
snap-back shows up as a settle sample reverting to the gesture-start reading.
A gap between `pointer` and `applied` instead means snapping pulled the group off
the cursor, which is a different fault with a different fix.

* chore(studio): name the path that clears a selection after a group move

The drag trace showed the group landing exactly where it was dropped and staying
there — no snap-back at any settle sample, and the pointer and the applied delta
never more than 2px apart — but two milliseconds after the drop the selection was
cleared with seven members still in it.

The clear comes from the timeline sync deciding the timeline holds nothing, and
that branch said nothing. It says so now, along with whether it is about to act
on it. The mirror alongside it reports how many members it managed to publish and
whether the anchor was among them, because a member with no timeline row of its
own resolves to null and is dropped silently — publish none and the sync reads it
back as an empty selection.

* fix(studio): losing one member of a group no longer deselects all of it

After a move the preview re-syncs and the selection is re-resolved against the
new document. When the primary could not be found there, both re-resolve paths
cleared the entire selection — so a group of five, all still on screen, was
deselected because one of them failed to resolve. The trace showed the clear
landing 600ms after the drop with five members still held, and the timeline sync
running afterwards on an already-empty canvas, which ruled it out as the cause.

A live group now re-resolves as a group and keeps whoever survived, picking a new
primary from them; it only clears when nobody did. That is what
refreshDomEditGroupSelectionsFromPreview was written for — it existed and was
never called.

Both clears also say which one they are and how many members were held, so if
this is not the last of it the next trace names the path immediately.

* feat(studio): carry a multi-selection in the URL, and name the member that breaks away

A link to a bug hit with several elements selected only reproduced one of them,
so the report read as "works for me". The hash now carries the rest as selGroup
and reopens the whole selection; members whose element is gone are dropped rather
than failing the others. Verified end to end in a real browser: select three,
copy the hash, open it fresh, the same three come back.

The drag trace also gains a rigidity check. A group moves as one object, so every
member travels the same distance; one that does not IS the fault. Drift was being
computed but only printed on every eighth frame, which is exactly how a
single-frame divergence hides — it now prints on the frame it happens.

The frame handler moves to its own module on the way past. It had grown a snap
block and a trace block inside a function already juggling four gesture kinds,
and it was over both the complexity and file-size gates.

Not fixed: the jump itself. Two headful runs driving a real group drag showed the
members staying rigid to the pixel, at the drop and 900ms after, so I have not
reproduced it yet and will not guess at a fix.

* fix(studio): stop snapping from moving a selection you have not dragged yet

Your log caught it on the first frame of the drag: pointer "0,0", applied "4,-3",
and all four members jumped 12,-8 composition px before the pointer had moved at
all. An element resting within the 6px snap threshold of a guide is already
snappable, so the snap computed on frame one closes that gap immediately —
picking the selection up moves it.

Snapping now sits out until the gesture has travelled the same 4px a drag needs
to count as a drag rather than a click, on both the group and single-element
paths. Nothing below that distance moves anything, and a real drag snaps exactly
as before.

The test builds a box resting 4px from a guide and asserts the ungated call still
returns dx 4 — the very displacement from your log — while the gated one returns
0 for a pointer that has not moved.

* fix(studio): a dropped group stays selected

Your Jam confirmed the first-frame jump is gone — pointer "0,0" now reads
applied "0,0" — and caught what was left: two milliseconds after each drop, a
`[hf-select] clear` with the group still holding three, then four members.

Every pointerup trails a click. The group gesture ref is cleared before the
commit runs, so by the time that click arrives the box no longer looks busy and
it reaches the canvas as an ordinary click — landing in the gap between the
members, resolving to nothing, and clearing the selection the drag just moved.
The under-threshold path already ate that click; the committed path never did.

The flag is now set before the two paths diverge, so neither can forget it. The
test drives a real pointerup through the handlers and fails on the committed
path with the flag moved back down.

* feat(studio): marquee from anywhere on the canvas, including outside the frame

An element dragged past the edge sits out in the grey, and the rubber band
refused to start there — it only began when the press landed inside the
composition rect. The one gesture that could reach those elements could not be
begun near them, so the timeline was the only way to select something plainly
visible on screen.

The collecting half never had that limit: it compares rects in overlay space and
never clipped to the frame, so those elements have always been selectable once
the band could begin. Only the start gate had to go.

A press in the grey that never travels still commits an empty selection, which is
the deselect it used to be, so the old behaviour of clicking out there to clear
is unchanged.

* refactor(studio): keep the selection files under the size cap

The selection work above pushed four files past the 600-line gate. Same
split the branch made later, landed with the changes that caused it.

* fix(studio): preserve selector groups in share URLs

* fix(studio): close multi-selection review gaps

* fix(studio): stabilize selection store reads

* fix(studio): preserve canvas-only group anchors

* fix(studio): stop a group drag from jumping one element back

Dragging several elements at once and dropping them made one of them snap
back to where it started for a frame or two, then jump forward again.

Each member of the group is written separately, and every write patched the
live GSAP tween in place and then seeked the player. A seek re-renders the
WHOLE timeline, not the tween that changed, so the members still queued
behind that write got repainted from their un-patched tweens: back to their
pre-drag position, where they sat until their own write landed. Only members
whose tween actually renders at the playhead showed it, which is why a group
of three flashed one element and left the others still.

The group commit now defers the seek for every member but the last, so the
queued members keep the transform the gesture left on them and the whole
group repaints once, from the fully patched timeline.

* perf(studio): commit a group drag in one request

Dragging N elements cost N writes and 9 reads for a three-element group: each
member fetched the composition's parse to preflight, fetched it again to
resolve its tween, then wrote the file on its own round trip. Every one of
those writes re-read, re-parsed and re-serialized the whole composition.

Three changes, same behaviour:

- The parse endpoint shares an in-flight request per file, so callers asking
  for the same composition at the same moment get one request. Only
  overlapping calls share — the entry is dropped as soon as it settles, so a
  read after a write still gets a fresh parse.
- The group preflight runs its members together instead of one at a time. A
  preflight writes nothing, so there is nothing to order.
- Members' mutations are queued and sent as one batch write. Anything that
  re-reads the file flushes the queue first, so a member resolving a shared or
  stale tween never reads a composition missing writes it is about to build
  on. The batch carries each member's runtime patch, and only the last one
  re-renders.

A three-element group drag now issues 2 reads and 1 write, down from 9 and 3.

* fix(studio): harden batched drag commits

* fix(studio): carry deferred preview fallbacks

* chore(studio): name whoever puts the pre-resize size back

Resizing the card commits correctly — the source and a fresh load both read
273x181 — but 200ms after the drop, mid-commit, the element renders at 395x261
with the studio size vars still holding 273x181. Something writes the
pre-gesture size back inline while the reload is still in flight, and every
writer of that size was silent.

Both are traced now under the existing hf-resize-debug flag, each with the size
going in, the size being replaced, and a short stack. Restoring the pre-gesture
size is right on a cancel and wrong after a successful commit, and the function
doing it cannot tell the two apart from the inside — so the caller has to be
named before this can be fixed at the right end.

* fix(studio): hold a resized element's size while the timeline is rebuilt

Your log caught it across two resizes. The first commits 305x202 and the element
is 305x202 at the drop; 200ms later it renders 395x261, its stylesheet size,
while --hf-studio-width still reads 305. The second gesture then starts with
`actual` at 305 against a live box of 395, and its very first move — a pointer
delta of 0.1px — snaps the element back to 305. That snap is the jump.

The gap belongs to the soft reload: it reverts the old timeline before building
the new one, and GSAP hands back each tween's recorded starting width on the way
out. Nothing held the size in between, because the seek reapply that exists for
exactly this stands aside for elements GSAP animates.

Standing aside is right for the offset — those channels compose, and applying
both doubles the move — and wrong for size, where both channels write width and
height so the later write simply wins on the same committed number. It applies
now. Only an element mid-edit carries the vars, so nothing else is touched.

A test seeks an element whose size GSAP owns after the revert put the stylesheet
size back, and fails with the skip restored.

* refactor(studio): keep the resize files under the size cap

* docs(studio): fold the resize note into the size-reapply comment

* fix(studio): rotate the child outlines with the element they outline

Selecting a rotated element drew upright dashed boxes across its children:
the chrome co-rotated with the element and the child outlines did not, so a
text layer inside a rotated card got a square outline lying across the
rotated glyphs.

The chrome already measures an oriented box; the child outlines were still
measured axis-aligned. They now use the same oriented measurement and render
with the same rotation. An unrotated element measures identically to before,
since the oriented rect returns the plain bounding box at angle 0.
2026-08-09 16:58:34 -07:00
Miguel Ángel cf45c98454 fix(studio): respect GSAP transform ownership (#2986)
* fix(studio): respect GSAP transform ownership

* fix(studio): enforce GSAP edit ownership consistently
2026-08-04 19:08:02 +00:00
Miguel Angel Simon Sierra 538264463c refactor(studio): tighten the expanded-lane cache and height helpers
Validate the parse response before reading `.animations` instead of casting
the JSON blind, and narrow the fetch's return type to the slice callers read.
Route the AST cache load through the shared clip-keyframe and cache-key
helpers so it can't drift from the other writer. Drop the unused numeric
track-count branches from `trackHeights`/`getTimelineCanvasHeight`, and pick
the widest keyframed clip with a reduce so there's no index assertion.
2026-07-28 00:40:44 +02: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 5f12e692d5 fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics (#1366)
* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics

Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.

- Retry code-editor saves with exponential backoff instead of dropping the
  edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
  queue with a user-visible error state instead of burning every queued
  mutation against it.
- save_failure events now carry error_message, status_code, and source on
  every emission path; style/attribute DOM-edit failures that previously only
  logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
  undo/redo, text fields) through a safe wrapper that reports failures via
  telemetry instead of unhandledrejection.

Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.

* fix(studio): narrow save retry changes for fallow
2026-06-12 22:02:17 -04:00