TimelineClipDiamonds.tsx had grown past the 600-line studio file gate, and
its keyframe/props types were declared a second time next to the ones the
lanes already share. The types, the render constants, and the keyframe
identity helper move to timelineDiamondTypes.ts; the rendering stays put.
The collapsed clip row dropped a keyframe's property group and animation id
before handing it to a callback, so the same keyframe hashed to a different
selection key than the expanded property lane did. Selecting a diamond in one
view left it unselected in the other, and retime/delete on the collapsed row
lost the animation id they use to pick between two animations that collide at
one percentage.
Diamonds now always carry their full identity, the collapsed shim just curries
the element id, and Timeline reuses useTimelineKeyframeHandlers instead of its
own inline copy of the same three handlers.
Neighbour geometry moves into one marker record per diamond, which drops the
index-lookup non-null assertions the connector pass needed.
Two sibling call paths still answered from GSAP's 0.5s default while the
toggle path had moved to the clip-wide fallback.
isPlayheadWithinTween now takes the selection, so the toolbar stops
promising "extends animation" on a duration-less tween whose click actually
toggles an interior keyframe. The arc drag commit resolves its replacement
duration the same way its click-path sibling does, instead of authoring
duration 0.5 and collapsing the arc window.
The flat text section drops its two marker-clearing effects: the autofocus
marker is a ref now, read and cleared by the render that consumes it.
Each timeline keyframe callback now reads and writes through the SAME
element: the clicked element's animations resolve the target, its selection
commits the mutation, and its animation computes the playhead percentage.
onMoveKeyframeToPlayhead previously took the percentage from the clicked
element and the animation plus selection from the current one, so a context
menu on a non-selected diamond retimed against one tween and wrote into
another file. onChangeKeyframeEase and onToggleKeyframeAtPlayhead had the
same split. An explicit null selection override now aborts the write instead
of falling back to the current selection.
The timeline-element animation lookup rebuilt the three cache-key variants
by hand. elementCacheKeys already owns that list for the writers, so this
reader takes it from there instead of drifting from it.
Drag-to-retime resolved the dragged diamond against the selected element's
animations and committed through the selected element's DOM selection, so
dragging a diamond on a non-selected clip retimed the wrong tween. It now
resolves against the clicked element's animations and commits through that
element's selection, matching the delete path.
The three diamond callbacks also take the TimelineKeyframeTarget they already
had instead of five positional fields, and the two copies of the
sourceFile#domId split share splitTimelineElementKey.
The lane-header keyframe toggle fires on whichever element owns the lane,
which need not be the selected one. The remove path looked the animation up
in the selected element's animations, so a non-selected element's flat tween
missed and silently took the remove-one-keyframe branch, stranding the tween
instead of deleting it.
Three review follow-ups on the editor-callback consolidation.
The keyframe-target resolve now takes the clicked element's key and reads
that element's keyframe cache. The diamond context menu and move-to-playhead
pass no explicit target, so they fell through to the cache of whatever
element happened to be selected: opening the menu on a non-selected
element's diamond resolved against the wrong keyframes.
PropertyPanelFlat opens the Motion group by adjusting state during render
instead of in an effect, so the AnimationCard mounts on the same commit the
focus request arrives on rather than a frame later.
Both animation sections pass a module-level focus consumer instead of a
fresh inline arrow, so AnimationCard's focus effect stops re-running on
every parent render.
LegacyTrackHeader reads as deprecated code. It is the live path for every
track that has no keyframe clip to disclose, so call it PlainTrackHeader and
say so in a comment.
The header file owned value sampling, readout formatting, lane-state
resolution and the JSX at once, so a formatting change and a layout change
edited the same file. Sampling and formatting now live in
trackHeaderLaneValues, lane-state resolution in trackHeaderLaneState, and
resolveLaneHeaderState returns only the four fields its caller reads.
Also shows the track's clip count next to the track identity, which the
header promised but never rendered.
The prev/next keyframe chevrons and the group toggle diamond let their
click bubble to the ancestor track row, so seeking to a keyframe also
reselected the track. The disclosure caret and the eye already stop it;
these now match.
Truncated labels (layer name, track label, group label, value readout)
also carry a title so the full text is reachable on hover.
CONTRIBUTING.md asks for a guard clause rather than a non-null assertion outside
an already-checked path. The index check and the lookup are now the same guard.
Escape now ends an in-flight diamond drag the way it already ends clip
and element drags: the armed gesture is marked cancelled, the preview is
dropped, and the pointerup that follows is swallowed instead of falling
through to the click branch.
The preview also flushes once per animation frame instead of once per
pointermove, so a high-rate trackpad no longer re-renders every diamond
in the row several times a frame. Single-diamond retime stays the
documented scope; multi-select drag needs a batched mutation the script
ops do not express yet.
Rows stopped sharing one pixel height when lanes gained expansion, so the only
production caller was passing cumulative row coordinates with trackHeight 1 and
both scrollTops zeroed. The parameter names described units the values no longer
carried. The vertical axis is now a row index and the caller keeps ownership of
folding scroll and per-row heights into it.
The local extractIdFromSelector duplicated the `#id`-only regex that
idFromSelector replaced, so both DOM-less paths in
resolveSelectorElementIds (no-iframe fallback and querySelectorAll-throw
recovery) read no id at all for the bracketed `[id="..."]` form writers
emit for CSS-unsafe ids. Deleted the duplicate and imported the shared
reader; both forms now resolve.
Dragging the playhead to the start of the composition needed a very slow
drag. The scrub surface begins GUTTER + TRACKS_LEFT_PAD px right of the
viewport edge, and both scrub paths bailed out when the pointer sat left of
that origin rather than clamping. So the last 80px of the drag toward zero
silently did nothing: the playhead stuck at whatever the last in-range sample
reported, and only a drag slow enough to sample inside the thin sliver before
the origin ever reached 0.
Both paths now share getTimelineScrubTime, which clamps to [0, duration]. One
owner, so the live-feedback path and the committed-seek path cannot disagree
about the edge again.
The R1/R3 residuals on this PR were fixed at the top of the stack, so they
only cleared once every branch above landed. They belong here, next to the
code they correct:
- `idFromSelector` inverts `idSelector` for both regex readers, so the
post-commit cache refresh stops skipping the CSS-unsafe ids `idSelector`
exists to support.
- `deduplicateKeyframes` drops `ease` when it is ambiguous; the flag was the
only honest answer and the last-writer-wins curve belonged to an arbitrary
colliding tween.
- `isStaticPositionHold` is now the single owner of the hold skip. The
`sourceAnimations` filter and the `allKeyframes` filter had diverged on
whether `immediateRender` counts as a property.
- The keyframe-cache setters no-op when the write changes nothing, instead of
handing every subscriber a fresh Map.
- `reset()` clears `focusedEaseSegment`.
- The test hook `delete`s its window key rather than setting it to undefined,
so feature detection still works.
- The `toClipKeyframes` fixture uses `as unknown as T` with the justification
CONTRIBUTING.md asks for.
R3 review follow-ups on the keyframe cache:
- clearKeyframeCacheForFile collected ids from the index.html alias prefix
too, so a re-scan of one composition file wiped rows a sibling file had
just written (several files re-scan concurrently). Only the file's own
prefixed keys name the ids now; clearKeyframeCacheForElement still takes
the alias and bare key with them.
- toClipKeyframes fell back to a fixed 1s tween duration, which put a
duration-less tween's keyframes at a percentage no edit path agreed with.
It now spans the clip, matching resolveEditableTweenDuration.
- collectAnimatableKeyframeProperties takes `object` so call sites drop
their `as Record<string, unknown>` casts.
Regression tests cover both fixes.
Each keyframe-cache writer re-derived a clip-relative percentage inline, and the
post-commit writer rounded to 0.1% while the others used 0.001%. Selection keys
embed that number, so a commit-time rewrite could orphan a live key.
toClipPercentage owns the rounding, toClipKeyframes owns the whole row (percentage
plus the tween percentage and animation identity the lanes read), and the parsed
write reuses elementCacheKeys instead of open-coding the three key variants.
An ungrouped tween (mixed property groups classify to propertyGroup
undefined) fed keyframeCache but was skipped by every gsapAnimations
writer, so the collapsed row drew diamonds the expanded lanes had no
source animation to render. Drop the property-group gate at all three
writers; lane consumers already filter by group.
Also route the same-percentage merge in updateKeyframeCacheFromParsed
through deduplicateKeyframes so the easeAmbiguous rule has one owner.
* fix(studio): play preview audio at speeds above 1x
The player force-muted preview audio whenever playback rate exceeded 1x
and disabled the mute button (and the M shortcut) in that state. Users
changing speed to 2x heard nothing, and clicking unmute did nothing.
Media elements play audio fine at any rate (pitch preserved by default),
so remove the special case: audio now follows only the user's mute
toggle at every speed. Drops the shouldMutePreviewAudio helper and the
audioAutoMuted UI/keyboard gating that existed solely for it.
* test(studio): update seek audio test for unmuted-above-1x behavior
useTimelinePlayer.seek.test.ts still asserted the old force-mute: at 2x
with the user unmuted it expected a set-muted{muted:true} message. With
audio now following only the user's toggle, the preview receives
set-muted{muted:false}. Update the assertion and title to match.