Commit Graph
7 Commits
Author SHA1 Message Date
Vance IngallsandClaude Opus 5 a05f16c5e6 fix(studio): don't offer layout grouping for a selection of audio clips
Selecting two audio clips offered "Group selection", which is the layout
grouper: it wraps the members in a positioned <div> at their bounding
box, rebases each child's left/top against that origin, and adopts the
topmost member's z-index.

None of that means anything for audio. An <audio> clip has no box —
offsetWidth/Height are 0 — so the wrapper came out `width: 0px; height:
0px` with inline left/top written onto elements that are never laid out,
and the composition gained a <div> standing for nothing audible.
Confirmed against `wrapElementsInHtml` directly: it matched and wrapped,
producing exactly that.

Refused in `handleGroupSelection`, which is where the G shortcut also
lands — a hidden button cannot gate a keystroke. The panel withholds the
button as well, so the refusal is not the first the author hears of it.

The message names the alternative rather than only declining: audio's
answer to "these clips belong together" is an <hf-audio-group> bus, which
the timeline's own FX pointer already creates. A mixed selection is
refused too — the wrapper would take the audio in with the rest.

`isAudioDomElement` sits beside `isAudioTimelineElement` and delegates to
it, so the selection layer and the timeline cannot drift into disagreeing
about what counts as audio.

Six tests across both entry points, each mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:16 -07:00
Miguel Ángel ec0b23f3ce fix(studio): make Delete remove the whole canvas selection (#3339)
* fix(studio): delete every clip in the selection, not just the first

Select all in the timeline, press Delete, and one clip disappeared while the
rest stayed — still drawn as selected.

The Delete hotkey built the selection set correctly and then called
`elements.find(...)`, which stops at the first match, and handed that single
element to a handler that deletes exactly one. The comment above it claimed the
handler "expands a clip that is part of the multi-selection into an atomic
delete of the whole selection (single undo)" — no such expansion existed
anywhere; `useTimelineEditing` never read `selectedElementIds`.

`handleTimelineElementsDelete` takes the whole selection and removes every
element before saving once, so the delete is a single history entry and a single
undo — what the comment already promised. The hotkey layer now takes only that
plural handler, since it never deletes one element in isolation; the singular
entry point stays for the context menu and clip chrome. The store drops every
deleted key and clears the marquee set, rather than leaving a selection drawn
around clips that no longer exist.

Elements whose `sourceFile` is not the composition being edited are dropped from
the pass rather than written to the wrong file.

Also removes the preview's double-click-to-reset-zoom. It was a document-level
capture listener, so any double-click anywhere over the viewport snapped the
zoom back to fit — including double-clicks meant for the content under it. The
explicit reset control beside the zoom HUD stays.

Reproduced by test: restoring `elements.find` reds the new marquee case.

* fix(studio): delete every canvas element in the selection, not just the primary

Selecting several elements on the canvas and pressing Delete removed one of
them and left the rest — still drawn as selected. The delete path only ever
took the primary selection; the marquee group it belongs to was ignored.

Expand the session-level delete through the group ref, the same way the other
group commits already do, and let the lifecycle op remove every member under a
single save so one Undo restores the whole selection.

* fix(studio): let the canvas selection own Delete instead of its timeline mirror

Marquee-selecting elements on the canvas and pressing Delete removed a
fraction of them. The hotkey routed to the timeline delete whenever the
timeline store held anything, and the timeline's copy of a canvas selection is
derived and lossy by construction — a member with no timeline row of its own is
dropped from it. Selecting 73 elements published 14 ids, so 14 went and 59
stayed, still drawn as selected.

The canvas selection is what the user drew the marquee around, so it owns
Delete whenever it holds something; the timeline path stays as the fallback for
rows with no canvas node to select. Both paths already remove through the same
endpoint, so this is one addressing scheme replacing two.

That makes the canvas delete the path a Delete press normally takes, so it
picks up the same mid-recording refusal the timeline delete has.

* fix(studio): let the marquee see the whole document, not the first 80 elements

Dragging a marquee over the entire canvas selected a fraction of what it
covered, so Delete left most of the page behind. The hit test sourced its
candidates from the layers-panel collector, which stops after 80 items — a
budget for how many rows that panel is willing to render, silently reused as if
it described the document. Everything past the 80th element in document order
was unselectable no matter where the user dragged. The off-canvas indicators
were reading the same truncated list.

The cap now belongs to the panel that wants it; the collector returns
everything. To pay for that, the marquee measures its candidates once when the
drag passes the threshold instead of re-reading layout for every element on
every pointer-move: unbounded plus per-move stalled the tab outright, and the
iframe DOM does not mutate mid-drag, so one pass stays true for the gesture.

On a captured page: one marquee, one Delete, 734 elements down to 81.

* fix(studio): report a no-op delete instead of claiming the elements went

A target the file no longer holds answers `changed: false`, which is normal
for a member nested inside another member already removed. Every target
answering that is not — it means the preview is describing a document the file
does not have, so each removal misses and the file is written back untouched.

The toast still said "Deleted 503 elements. Use Undo to restore them." That is
how a delete that did nothing at all looked from the outside: press Delete, the
page stays, nothing on screen explains it. Say the preview is out of date and
reload it instead.

* fix(studio): keep the canvas hotkeys alive across preview reloads

Pressing Delete with a canvas selection did nothing at all — no removal, no
toast, nothing on screen to explain it. A keypress goes to whichever document
has focus, and clicking the canvas puts focus inside the preview iframe, so the
app's hotkeys have to be forwarded there.

They were, but only from the iframe element's ref callback, which fires when
the element mounts. A preview reload keeps the same element, so the callback
never runs again, and keeps the same WindowProxy, so the forwarder's identity
check saw no change and skipped re-attaching — while the inner window holding
the listeners had been replaced. After the first reload the canvas had no app
hotkeys left. Undo and redo kept working because their forwarder re-attaches on
every load, which is why this read as "only Delete is broken".

Fold the app handler into that per-load forwarder so both attach in the same
place, on every load, and drop the mount-only one. Window only: the history
pair also listens on the document, and capture listeners on both would run the
app handler twice per press.

* perf(studio): stop re-probing every restored selection member on load

The hash carries the whole canvas selection, and restoring it asked the
server whether each member still exists in the source — one request per member,
awaited one after another. A marquee over a captured page puts hundreds of
members in the URL, so every later load of that URL spent hundreds of serial
round trips rebuilding the selection before the canvas answered anything,
keypresses included.

The marquee that produced those members already skips the probe. Restoring them
skips it too; only the primary, whose panel reads the flag, still pays for one.

* fix(studio): delete a canvas selection in one pass and say the key landed

Reproduced with a real, focus-routed keypress instead of a synthetic one: the
press does reach the handler and the delete does run to completion, but at
hundreds of members it takes seconds during which the canvas is unchanged and
nothing acknowledges the key. Silence for that long is indistinguishable from
Delete being broken, and pressing it again or reloading mid-flight lands in a
worse state.

Two things, one per cause. The removal now sends the whole selection in a
single request against a new remove-elements route, which reads the file once,
drops every member and writes once — it was a round trip AND a full rewrite of
the file per element. And a multi-element delete announces itself before the
work starts, so the press is visibly acknowledged instead of leaving the canvas
looking untouched until it finishes.

Measured on a captured page, 84 members: 933ms of serial round trips against
84 rewrites, down to 583ms and one.

* refactor(studio): narrow the SDK delete targets instead of asserting them

The batch SDK path guarded on every member having an hfId and then asserted
it away per member. Narrow once into a string list so the guard and the values
come from the same place, and drop a threaded content variable that never
changed — the SDK owns the document it edits, so every member is removed
against the same starting content.

Also mounts the new forwarding test through the existing harness rather than
repeating its setup.

* fix(studio): stop Delete acting on a canvas selection the user replaced

Two things the reordered Delete arbitration got wrong, both found in review.

A clip with no canvas node left the canvas selection pointing at whatever was
picked before it, and the canvas branch wins whenever that ref is non-null — so
selecting an audio clip and pressing Delete removed the previously selected
canvas element and left the clip, right after the toast said the clip was not
in the preview. The timeline fallback the comment described could not be
reached. Clearing that selection has to stay quiet: the clear is announced to
the timeline, so echoing it would deselect the clip that was just picked.

Expanding the primary to the marquee group also moved out of the delete handler
and up to the Delete key. Cut copies the primary alone, so expanding for every
caller put one element on the clipboard and removed every other member with it
— undo brought them back, paste restored one. The rule is a named function now,
so the two callers can differ without either guessing.

Also throttles the off-canvas indicator rebuild, which the cap had been hiding.
It walks every element in the preview and reads layout for each — measured at
6.5ms on an 825-element captured page against a 16.7ms frame — and what marks
it dirty is a MutationObserver on inline style, which is how animation writes.

* fix(studio): hold the canvas selection inside the timeline selection

The stale-canvas-selection defect survived at the second writer. The
store-driven sync bails when a member has not resolved yet and returned without
touching the canvas, so a pick with no canvas node at all left the previous
selection in place — and Delete acts on the canvas first, so it deleted that.
Reachable from the sidebar audio and asset reveals and from an asset drop, none
of which go through the handler already fixed.

Clearing on every bail would be wrong: the bail exists for a member whose node
is not ready, which a later run resolves, and clearing there would flicker.
Only a canvas anchor that resolves OUTSIDE the current selection goes, which is
the state that is dangerous rather than merely unfinished. Quietly, for the same
reason as the first writer: announcing would deselect the clip just picked.

The invariant is named now, since Delete depends on it: the canvas selection
never points outside the current timeline selection.

Also drops the x-hf-removed header, which nothing read and whose comment
promised a partial-vs-no-op distinction the response cannot make, and pins the
indicator throttle that was measured but uncovered.
2026-08-19 00:22:26 -04:00
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 0a70ba6717 fix(studio): scope timeline ease focus lifecycle (#2710) 2026-08-03 23:06:46 -07:00
Miguel Angel Simon Sierra 10d45def05 feat(studio): bulk-edit easing for merged keyframes 2026-07-29 03:39:51 +02:00
Ular KimsanovandMiguel Angel Simon Sierra 89db718899 feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides

Track order = default paint order; authored z = advanced override.

- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
  timeline lane move — closest track in the action's direction that is free
  over the clip's whole span, else a new lane adjacent to the crossed
  neighbor; temporal-overlap scope (default pending product sign-off, see
  module doc); visual zone only; same-file reference scoping; persistTrack
  via the shared authored-space rules. null for non-clips (menu stays
  z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
  persists the lane move through the same machinery as a timeline lane drag
  (optimistic store update, authoredTrack refresh, rollback); inserts reuse
  commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
  writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
  entry (test proves it over the real history reducer). The mirror never
  triggers the lane->z stacking sync, so it cannot fight the z values the
  action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
  contradicts lane order among temporally-overlapping same-context visual
  neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
  a 'z' badge — authored z overrides are surfaced instead of silently
  disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
  (600-line cap).

* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists

Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).

zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.

* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu

- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
  skip the GSAP fallback round-trip and the preview reload entirely — the
  renderer never reads data-track-index, and the live DOM patch + optimistic
  store update cover the UI. Mixed batches keep current behavior. Kills the
  canvas blink on mirrored Bring/Send actions (live-verified: an
  iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
  directional arrow for Forward/Backward; pierced two-layer stack for
  Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
  clip and every clip after it on that lane left by the clicked gap's width;
  leading gaps count, so a single clip with empty space before it compacts
  to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
  in timelineGaps.ts; persists through the drag path's atomic batch move
  (one undo per action); refuses when a clip that must shift is locked;
  items disable when there is nothing to close.

* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order

Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.

The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.

Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).

Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.

* fix(studio): bound forward/backward mirror to a one-element step

User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.

* feat(studio): permanent gap-menu rows with hover and click-select gap highlights

- TrackGapContextMenu always renders both rows; an inapplicable action dims
  with a tooltip ("No gap here" / lock reason / "No gaps on this track")
  instead of vanishing into a one-item menu. Width badge only when a gap
  exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
  timeline: the single gap for Close gap, every current gap (leading included)
  for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
  present-state intervals (epsilon-tolerant, overlap-safe), distinct from
  resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
  (suppressed for marquee multi-selection and during drags; the gap-menu hover
  wins on its own lane). Derivation lives in useTimelineGapHighlights with the
  pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
  + clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
  plumbing (ResizeObserver width + shortcut-hint sync) into
  useTimelineScrollViewport, behavior unchanged.

* feat(studio): stronger capcut-style timeline zoom steps

One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.

* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits

Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.

- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
  the canvas menu's realization core via resolveZOrderReposition (one
  between-z write when a strict gap exists, band-safe scoped renumber
  otherwise) instead of computeReorderZValues' all-sibling stamp — that
  helper is deleted, completing the #2347 unification follow-up. The drop
  then mirrors into a timeline lane move through the same machinery as the
  canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
  strictly between its NEW paint neighbors' lanes — nearest clip siblings in
  the desired render order, decorations skipped — else a track insert at
  that boundary; audio zone never crossed). Both writes share one
  per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
  is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
  factored into useMirrorLaneMoveCommit and reused by the new
  useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
  order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
  bump no refreshKey, so the panel's z-sorted order went stale while paused.
  handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
  rollback; the panel re-collects on it. Verified live: the panel re-sorts
  the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
  hidden at the current frame (animation-parked opacity, non-clip
  display/visibility hides, hidden ancestors) temporarily forces the chain
  visible with live inline styles — exact priors restored on deselect, on
  another reveal, on play, and on unmount; never persisted (file diff == 0
  verified live). Clips keep the existing seek-into-window behavior; the
  override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.

Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.

* feat(studio): full-track selection highlight, borderless gap hover strips

- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
  gap, inter-clip gaps, and the open space after the last clip to the rendered
  end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
  derivation). Still click-only: any drag/resize suppresses the strips, and a
  marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
  nudged to 0.18 alpha to keep the same visual weight.

* feat(studio): selected layer paints on top via a reader-transparent z lift

Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:

- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
  writes a far-top inline z; a static element gets a layout-preserving
  position:relative with its prior parked in data-hf-reveal-prior-pos. Only
  the RENDERER sees the lift: all three studio z readers
  (readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
  the parked prior while the attribute is present, so the canvas z-menu, the
  zOverride badge, the lane mirror, the stacking sync, and the panel sort
  keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
  play / unmount, each property only while it still holds the value the
  override wrote (a later real edit is never clobbered). File diff == 0
  verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
  reads the parked position for its persist-position:relative static check,
  then drops the attributes) — the committed z becomes the truth and the
  later restore is a guarded no-op.

* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path

Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:

1. Master-view path gate: activeCompPath is NULL at the master view, so the
   'paths[0] === activeCompPath' eligibility check could never match the
   index.html restore and every default-view undo full-reloaded at the first
   gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
   element's innerHTML, but the composition root wraps every clip — any child
   change re-detected at the root rejected the restore. Change detection now
   compares only each element's OWN attribute surface; structure/text
   integrity is still guaranteed by the normalize-residual whole-doc pass
   (text nodes, added/removed elements, and un-identified attrs all remain
   after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
   fell outside the diff entirely. Identity is now id OR data-hf-id, with the
   live sync resolving either.

Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.

Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.

* feat(studio): left breathing pad before t=0, double zoom sensitivity again

TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.

- The lanes and the ruler realize it as a plain flow spacer between the
  sticky gutter cell and the time-mapped content div, so every
  content-relative computation (clip left = t*pps, beat lines, lane-menu
  time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
  gap strips, drop placeholder, snap guide, range highlight, marquee clip
  rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
  range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
  pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
  all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
  center exactly under the pointer.

Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.

* fix(studio): left pad renders as true empty space, not lane surface

The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.

* fix(studio): no vertical line in the ruler band before 00:00

The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.

* refactor(studio): remove the timeline z-override badge

User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.

* fix(studio): collision-free expanded child lanes and host-window gap floors

Review findings D1 (blocker) and 4.

- D1: buildChildElements assigned expanded children synthetic display rows as
  `host.track + index` — integers that can EQUAL a real clip's lane in another
  file (host on 0 with two children puts child #2 on 1). Lane grouping merges
  purely by track number, so the collision fused clips from different source
  files into one display lane, and lane-scoped actions (the gap menu) then
  batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
  between the host's lane and the next integer — structurally unable to
  collide with any normalized lane, while still rendering as ordered rows
  under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
  display time is host-anchored — close/compact could drag it before its host
  window and persist a wrong (even negative) local time. All gap functions
  now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
  expandedParentStart for child lanes — single-origin per lane post-D1),
  threaded through the menu model, hover highlights, selected-lane strips,
  and both commits. Close-gap shifts clamp at the gap's own left edge.

* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity

Review findings 1, 2, and 3.

- Finding 1: buildTrackInsertEdits normalized the FULL display set and
  persisted every shifted clip — writing host-lane numbers into OTHER
  composition files when expanded children were showing. The renumber write
  set is now the edited element's own source file (the sanctioned multi-write
  converges one FILE to lane space, never neighbors' files); foreign clips
  keep their authored tracks and re-derive display lanes. The locked-clip
  refusal scopes the same way. Expanded-origin elements refuse the insert
  outright (a new lane is a host-space renumber, meaningless in the child's
  file), and the mirrors restrict an expanded child's lane candidates to its
  own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
  (persisting the sibling's authored track) but can never land on a host lane
  with no same-file occupant. authoredTrackForLane's offset fallback rounds:
  fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
  file can contain several CSS stacking contexts and leaf z is only
  comparable within one. Both resolvers now scope by samePaintScope — same
  source file AND same stackingContextId (the file check also stops null root
  contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
  duplicate class selectors (.sub) resolved to occurrence 0 — a different
  clip. The key now carries getSelectorIndex, matching how z-reorder entries
  derive theirs.

* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists

Review findings 5 and 7.

- Finding 5: commitDomEditPatchBatches resolved successfully even when the
  server matched NO patch target — the z write never reached disk (the
  preview reloads to reconverge) yet the lane mirror still ran, desyncing
  track order from what actually paints. The commit now resolves a durability
  report ({allMatched, changed}; the save queue and commit types are generic
  over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
  rides the timeline/SDK path — two queues, so a second rapid gesture's z
  write could land BETWEEN the first gesture's z and lane phases. Every
  z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
  through runZLaneGesture: a single module-level tail that serializes the
  COMPLETE two-phase transaction, with unit tests for ordering, the
  durability gate, and queue resilience to failed gestures. The timeline
  lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
  already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).

* fix(studio): multi-clip GSAP batch mutations roll back on late failure

Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.

* fix(studio): scope mirror inserts to their lane zone

* fix(studio): unify source-scoped clip identity

* fix(studio): isolate track insert topology

* fix(studio): harden timeline paint synchronization

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-07-14 14:31:58 -04:00
Vance IngallsandClaude Sonnet 5 c215a20c8e fix(studio): extend resolver-shadow runtime-node filter to timing/GSAP/reorder chokepoints (#1839)
* fix(studio): source-filter recordResolverParity (async + sourceHfIdCount)

* refactor(studio): extract cutover-eligibility checks to unblock filesize cap

* fix(studio): pass on-disk source to recordResolverParity at timing/gsap/delete chokepoints

* fix(studio): void the reorderElements recordResolverParity call (now async)

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

* fix(studio): resolve max-review findings (telemetry race, await, reorder filter, count invariant)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:37:48 -07:00