fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild#2347
Conversation
miguel-heygen
left a comment
There was a problem hiding this comment.
Review: restore golden timeline behaviors
Reviewed the diff three ways: PR vs main, PR vs golden (studio-dnd/consolidated-tip), and semantic verification of the files that were reinterpreted rather than copied. Method: for each of the six restore claims I checked that the regression on main is real, that the restored code matches golden, and (for the non-identical files) that the port preserves golden's semantics against main's post-#2291 surrounding code.
Overall this is sound and mergeable for its stated scope. Every regression claim I sampled is truthful, the six exact-restore files are byte-identical to golden with their dependency contracts intact, and the load-bearing soft-reload ("blink") fix is faithful to golden, including the authored-opacity restore path. Requesting changes for three small, user-visible fixes plus a documentation correction; the rest are follow-ups.
Fidelity summary
| Claim | Regression real | Restore faithful |
|---|---|---|
| #1 Gridlines + sticky ruler | yes | byte-identical to golden |
#2 Stale timelineZones (z to track lanes) |
yes | byte-identical; consumer contract holds |
| #3 Batch no-op silent fail | yes | faithful, but shares golden's latent flaw |
| #4 Canvas blink (soft-reload) | yes | core logic byte-identical; one new edge risk |
| #5 Duration grow-and-shrink | yes | server path correct; two gaps |
| #6 Stacking-sync NaN contract | yes | traced end-to-end, no || 0 coercion |
| Asset-drop restore + reveal/preview UX | net-new | three UX bugs |
The NaN exclusion chain (#6) was traced Timeline.tsx to timelineClipDragCommit.ts to computeStackingPatches with no coercion, so restoring readClipZIndex to return NaN correctly activates the pre-existing Number.isFinite filter. The soft/full reload gating (#4) is byte-identical to golden, and the mixed-file group edit does exactly one full reload when a change targets a non-active comp.
Please fix before merge
1. Stuck preview overlay when revealing an already-added asset
components/sidebar/AssetCard.tsx:151-158 (and the mirror in AudioRow.tsx): the used reveal branch does setSelectedElementId + requestClipReveal + return and never calls clearPreviewAsset. Repro: open the preview overlay on a not-yet-added asset A, then click a different, already-added asset B in the sidebar. A stays stuck open while the timeline scrolls to reveal B underneath it. The only in-scope callers of clearPreviewAsset are the overlay's own dismiss handlers and project-switch, so nothing clears it here. Add a clearPreviewAsset() to the reveal branch.
2. Duration readout is not rolled back on a failed write
Golden captures previousDuration and reverts both the store and the live root in its catch (timelineElementsMove.ts:312-314). The PR sets the duration optimistically via syncPreviewContentDuration at gesture release (useTimelineGroupEditing.ts:222,311) but has no previousDuration capture and no rollback. A failed save leaves the store and live root data-duration advertising a length that was never persisted, until the next reload. This is on the default (server) path, not gated by any flag. Restore golden's rollback.
3. Dismiss-on-playback is edge-triggered only
components/nle/AssetPreviewOverlay.tsx:102-108 evaluates shouldDismissAssetPreview only against future usePlayerStore.subscribe mutations. Playback drives currentTime outside Zustand in the RAF loop and only writes the store at the end of the range or on loop wrap, so a preview opened while playback is already running does not dismiss until then, contrary to the "dismiss on playback" contract. Check the current store state once at subscribe time (or gate opening the preview on !isPlaying).
Worth fixing while this code is open
4. Batch no-op skip cannot distinguish a legitimate no-op from a mistargeted member
hooks/timelineEditingHelpers.ts:328 treats patched === current as a skip. But applyPatchByTarget (via findTagByTarget in utils/sourcePatcher.ts) returns the string unchanged both when the element is already at the target value (legitimate no-op) and when the target is not found at all (a real error). So a wrong domId/hfId/selector is silently dropped instead of surfacing. This is faithful to golden (timelineElementsMove.ts has the same ambiguity), so it is not a regression this PR introduces, and it is why the primary claim (track-insert renumber no longer aborts) is correctly fixed. But the single-element path throws in exactly this case, so the two paths now disagree. Cheap root-cause fix: resolve the target with findTagByTarget first and throw on null, only treating byte-identical output as a no-op when the target actually resolved.
5. New silent stale-preview surface introduced by the history-fold extraction
hooks/timelineTimingSync.ts: foldGsapMutationIntoHistory runs the GSAP server rewrite, then does a readFileContent/recordEdit fold step afterward. If that post-mutation step throws, it propagates to finishTimelineTimingFallback's catch, which calls onGsapError (console only) and returns, discarding the already-rewritten scriptText with no syncTimingEditPreview and no reloadPreview. The preview is left showing the pre-rewrite GSAP positions with no recovery. Golden's inline path had no fold step between the mutation and the sync, so this failure mode is new to the extraction. Sync the preview in a finally, or catch the fold step separately so a history-record failure never suppresses the reload.
6. timelineRevealScroll has no guard for a degenerate viewport
player/components/timelineRevealScroll.ts:60: windowSize = windowEnd - windowStart is never guarded against <= 0. When the scroll container is smaller than stickyStart + 2 * padding (reachable at the minimum timeline height combined with the caption-mode footer), the oversized-clip branch still fires and returns a target that places the clip top at stickyStart + padding, off-screen in a viewport that small. The reveal silently scrolls but the clip stays hidden. No test covers windowSize <= 0. Add a guard and a test.
Non-blocking
- The description's claim that
timelineClipDragCommit.tsis "already golden" onmainis incorrect: its blob equalsmain's, not golden's (it is missing golden'sbeginTimelineOptimisticGesturerace guard, a separate feature). This is functionally harmless because thenormalizeToZonescall site is identical and only reads.track/.key, but the description misrepresents a dependency; please correct it. player/components/PlayerControls.tsxbundles an undisclosed cosmetic change (removal of the transport-barborderTop) not covered by any claim. Harmless, but call it out or drop it.- The SDK fast path (
sdkTimingPersist) writes clip attributes but not rootdata-duration, so a shrink routed through it would leave the persisted root duration stale. This is currently dormant (STUDIO_SDK_CUTOVER_ENABLEDdefaults false, and the caller falls through to the server path when it returns false), but it is a real gap to close before that flag flips on.
Duration edge cases (deleting the furthest clip, empty timeline, clip at time 0) are all handled correctly on the server path: setCompositionDurationToContent rejects a content end <= 0 and keeps the prior duration, and the start < 0 exclusion correctly counts a clip at start 0.
|
Second commit ( Vertical lane moves didn't persist — three independent causes, all fixed:
Review findings fixed: Timeline geometry: 20% fit-zoom trailing headroom (single shared constant), playhead line center exactly on Known follow-up (deliberately not in this PR): ~1,000 lines of dead old-model code (the z-driven vertical-drag pipeline: |
|
@ukimsanov rebase main |
miguel-heygen
left a comment
There was a problem hiding this comment.
Follow-up review: commit 2 (fefbde4) z/lane pipeline, single-source-of-truth pass
Thanks for the second round. The vertical-lane persistence fixes and the display-lane vs authored-track split are the right architecture, and the adversarial pass caught real bugs. Reviewing this commit specifically through a single-source-of-truth lens (one owner per decision, one authoritative source per field, no stale plumbing) surfaces that the fix is incomplete at two construction boundaries, ships one mechanism that is inert in production, and leaves two z-models coexisting. Findings are ranked; each was traced to file and line.
The common root cause
createTimelineElementFromManifestClip (packages/studio/src/player/lib/timelineDOM.ts:115) is the single translation boundary from the runtime manifest to a studio TimelineElement. It sets track: clip.track but drops two authoritative fields that the runtime computes: authoredTrack and stackingContextId. Findings 1, 2, and 4 all trace back here. Carrying both fields through this factory is the single-source-of-truth fix; the drag helpers should read them from a complete element rather than reconstruct them from display state.
Ranked findings
1. Critical: expanded sub-composition drags still persist a display lane into data-track-index.
The factory above never sets authoredTrack. buildChildElements (packages/studio/src/player/hooks/useExpandedTimelineElements.ts, around line 166) then overwrites track with display.track + offset, permanently discarding any authored value. authoredTrackForLane (packages/studio/src/player/components/timelineClipDragCommit.ts:169-175) only searches the top-level elements array, which does not contain expanded children, so dragging an expanded child onto an empty display lane returns the display-lane integer as the authored track. That is the exact coordinate-space bug this commit set out to fix, still reachable for sub-composition children on a sparse file.
2. High: the new stackingContextId partitioning is unwired in production.
stackingContextId is declared on the store type (packages/studio/src/player/store/playerStore.ts:44) and the wire type, computed by the runtime (packages/core/src/runtime/timeline.ts:404,513,568), and consumed by timelineStackingSync (packages/studio/src/player/components/timelineStackingSync.ts:301). But createTimelineElementFromManifestClip never copies it, and every runtime clip routes through that factory, so every production clip resolves to null (root context) and the per-context partitioning is a no-op. The new stacking tests inject stackingContextId directly, so they pass while production never exercises the wiring.
3. High: optimistic store updates never refresh authoredTrack.
A second drag that lands before the reload completes computes the authored track from stale store data.
4. High: spill-lane drops have no distinct authored identity, and the guiding comment is wrong.
authoredTrackForLane's docstring states overlap sub-lane spills are "never a lane-move target," but Timeline.tsx's trackOrder (grouped by el.track from packTrackLanes) includes every display sub-lane, including spill lanes, as legal drop targets. A drag onto a spilled lane is reachable; the persisted track then collapses on reload. Please fix the comment and the handling together.
5. Medium: the non-atomic single-element move path drops vertical-only moves.
packages/studio/src/hooks/useTimelineEditing.ts:144-161: applyTimelineStackingReorder runs, then if (!startChanged) return reorderDone returns before the file-track persist below it. A pure lane change (no time change) routed through the onMoveElement-only fallback writes nothing to the file.
6. Medium: two z-models coexist (stale plumbing).
The old z-reorder vocabulary (timelineLayerDrag.ts resolveTimelineLayerZIndexChanges, applyTimelineStackingReorder, TimelineMoveInput.stackingElement, plus their tests) is dead by dataflow: the drag-commit path passes onMoveElement a StartTrack with no stackingReorder, so applyTimelineStackingReorder (useTimelineEditing.ts:152) always early-returns on a null intent, and the only producer branch (timelineEditing.ts:132) is unreachable because timelineClipDragPreview.ts never passes stackingElement. No current drag double-writes z-index and data-track-index, so this is not a live bug. But the model is still typed, still executed during preview (it computes a stackingReorder that is then discarded at commit), and still test-covered, and the description's "zero production callers" is imprecise: applyTimelineStackingReorder has a live call site, just fed undefined. Deferring the deletion is reasonable, but please track it as stale plumbing rather than describe it as already dead.
7. Low: stackingContextId equality is recomputed in three places inside computeStackingPatches rather than through one canonical contextKey helper.
Test-coverage gap (the tell)
Every new test injects authoredTrack and stackingContextId directly rather than constructing elements through createTimelineElementFromManifestClip, which is why findings 1 and 2 shipped green. A single pipeline test from sparse authored tracks through expansion, drag commit, and file patch would have caught both. Please add coverage that crosses the real factory boundary.
Requesting changes for findings 1 and 2 (correctness and a dead feature); 3 through 5 are worth fixing in the same pass, and 6 through 7 are cleanup that can follow.
|
Third commit ( Resolver verdict: sound. Tie-aware (equal z → DOM order, matching CSS), minimal-churn fast path, band-constrained renumber fallback, no negative z, no unbounded growth. Live-verified: bring-to-front = one atomic single-element write; menu items disable correctly at the extremes from fresh state. Glue defects fixed:
Deferred to the dead-code cleanup follow-up: multi-file partial-persist orphaning (unreachable from the menu), unifying LayersPanel's stamp-everything Gates: all pre-commit hooks green; studio suite 2058 passing (same 18 pre-existing environmental failures). |
|
Fourth commit ( |
Review 1 (restore commit): - asset reveal now clears any open preview overlay (stuck-overlay repro: preview on A, click already-added B — A stayed open over the reveal) - duration readout rolls back on failed persist: captureDurationRollback snapshots store + live root data-duration before the optimistic sync and restores both in every move/resize/delete/group catch (golden's previousDuration pattern) - asset preview opened during running playback dismisses immediately (the RAF loop bypasses the store, so the subscription alone never fired) - persistTimelineBatchEdit resolves the target (findTagByTarget) before treating identical output as a no-op — a mistargeted member now throws like the single-element path instead of being silently dropped - a post-mutation history-fold failure no longer suppresses the preview sync: fold errors are surfaced separately and the rewritten script still syncs (previously the preview kept stale GSAP positions with no recovery) - timelineRevealScroll guards degenerate viewports (windowSize <= 0) - CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches Review 2 (single-source-of-truth pass): - createTimelineElementFromManifestClip — the one manifest->element boundary — now carries authoredTrack and stackingContextId; expanded sub-comp children preserve both (authoredTrack in their OWN file's space) - authoredTrackForLane scopes occupants to the dragged clip's sourceFile (a foreign file's authored values are a different coordinate space); nearest-same-file-lane offset fallback - optimistic store updates mirror the persisted track into authoredTrack (and roll it back on failure), so consecutive drags before a reload resolve from fresh data - spill sub-lanes: documented decision — dropping onto a spill lane is a legitimate same-track join (occupants share the authored track by construction); false 'never a lane-move target' docstring rewritten - single-element fallback persists vertical-only moves (early return now requires neither start nor track changed; live DOM patch includes data-track-index) - canonical contextKey helper for stacking-context normalization - new pipeline test crosses the REAL factory boundary (sparse authored tracks -> factory -> expansion -> normalize -> drag commit -> persisted attribute), no injected fields
|
@miguel-heygen Both reviews addressed in Review 1:
Review 2:
Full studio suite 2104 passing (the 18 failures are the pre-existing Node localStorage environment set, identical on pristine main); all pre-commit gates green. Re-requesting review. |
miguel-heygen
left a comment
There was a problem hiding this comment.
Approving: both review rounds verified fixed in 77c4fca
Verified the fixes adversarially against the tree (not taken from the summary), and ran the affected suites (timelineStackingSync, timelineTrackPersistPipeline, timelineClipDragCommit, useTimelineEditing) at 77c4fca, 84 tests green. Every blocking finding from both rounds is genuinely fixed:
Round 1
- Stuck preview overlay:
clearPreviewAsset()now runs in the reveal branch of bothAssetCardandAudioRow, with a component test mounting a preview open on a different asset. - Duration rollback:
captureDurationRollbacksnapshots store + live root before the optimistic sync and restores both on failure across move/resize/delete and both group-edit paths. - Dismiss-on-playback: the overlay now evaluates store state on open.
- Batch no-op root cause:
persistTimelineBatchEditresolves the target withfindTagByTargetfirst and throws on null, so identical output is a no-op only after the target resolved. This is the mistarget-vs-no-op distinction I asked for. - History-fold failure split: a post-mutation fold error is surfaced via
onFoldErrorand the rewritten script still syncs; a mutation failure still skips the sync. Two distinct tests. - Degenerate viewport guard and the three
encodeURIComponent(projectId)fixes confirmed.
Round 2 (single-source-of-truth)
- Findings 1+2:
createTimelineElementFromManifestClipnow carries bothauthoredTrackandstackingContextId;buildChildElementspreserves them on expanded children;authoredTrackForLaneis scoped to the dragged clip'ssourceFile. The newtimelineTrackPersistPipeline.test.tscrosses the real factory boundary (sparse tracks 3/7 through factory, expansion, normalize, commit) and asserts the persisted attribute is the authored value, no injected fields. This closes the critical persist bug and wires the previously inertstackingContextIdpartitioning. - Finding 3: optimistic writes mirror the persisted track into
authoredTrack(rolled back on failure), so a second drag before reload resolves fresh. - Finding 4: the false "never a lane-move target" docstring is corrected and a spill-lane drop is handled as a same-track join.
- Finding 5: the single-element fallback now persists vertical-only moves (early return requires neither start nor track changed; live patch includes
data-track-index; SDK start-only path bypassed when the track changed). - Finding 7: a canonical
contextKeyhelper now ownsstackingContextIdequality. - Finding 6: accepted as stale plumbing, deletion tracked for a follow-up cleanup PR. Framing corrected.
Two non-blocking follow-ups (fine to land after)
- Duration rollback has failed-persist tests for move/resize/group but not for the delete path. The delete-path code is correct by inspection; add the assertion to match.
- The dismiss-on-playback on-open check inlines only the
isPlayingbranch rather than reusing the sharedshouldDismissAssetPreview, so a preview opened at the exact moment a seek is already in flight (isPlaying: false,requestedSeekTime != null) is not dismissed untilcurrentTimediverges. Narrow, and a small duplication of the dismiss predicate. ReuseshouldDismissAssetPreviewon open to close both.
One thing outstanding before merge
The branch is not rebased onto main (merge-base is 95fa14b2; main has since advanced). That rebase was requested earlier and still needs to happen before this goes in.
Approving on correctness. Nice work on the point-by-point turnaround, and on the pipeline test that crosses the real factory boundary, which is what makes findings 1 and 2 stay fixed.
miguel-heygen
left a comment
There was a problem hiding this comment.
Please rebase onto main instead of merging main in
The code is good, I re-verified that the five PR commits are content-unchanged from the head I approved (range-diff clean) and CI is green on the current head. The only issue is history hygiene.
The conflict was resolved by merging origin/main into the branch (merge commit 8013b40bf, "Merge remote-tracking branch 'origin/main'..."), but we keep this repo on a linear history. Please rebase onto main instead so the branch is the five fix commits replayed on top of current main, with no merge commit:
git fetch origin
git rebase origin/main
git push --force-with-lease
Once it's rebased clean (no merge commit, linear on main) I'll re-approve right away. Nothing about the code needs to change, this is just to drop the merge commit and keep the history straight.
…stack rebuild The Studio stack rebuild (#2291) landed the remaining NLE layers but dropped or regressed several final-wave behaviors from the reviewed studio-dnd stack, and never repaired the stale timelineZones.ts that #2279 introduced. Restores: - TimelineRuler: sticky under vertical scroll, full-height gridlines removed (beat lines only), frame-number tick labels via a persisted timeDisplayMode store preference (PlayerControls toggle now store-backed) - timelineZones: stable track lanes — lane = authored data-track-index ascending; z is paint order only (replaces the stale z-driven lane pack, which broke track insert-band commits that contractually depend on it) - persistTimelineBatchEdit: a batch member whose patch is a no-op (attributes already at target values, e.g. in a track-insert renumber) is skipped instead of aborting and rolling back the whole batch — this alone made new-track creation (incl. the top insert band) fail silently - useTimelineStackingSync: unresolvable clips read as NaN again so timelineStackingSync's Number.isFinite exclusion contract holds (z=0 fabrications skewed stacking boundaries) - timelineAssetDrop: drops land on the drop track (no overlap bump to max-track+1), data-hf-id stamped, audio gets data-volume - timing edits: soft-reload the server's rewritten GSAP script instead of a full iframe remount (no all-clips flash on move/resize); full reload only when no scriptText or the soft path can't apply, and one full reload when a group edit touches non-active files (new hooks/timelineTimingSync.ts) - duration: content-driven grow-AND-shrink on move/resize/delete, synced optimistically to the store and the live root data-duration at release (was a grow-only ratchet; shrink never updated the readout) New UX: sidebar asset click opens a compact non-modal preview over the canvas (dismiss on outside click, Escape, playback, or seek), and clicking an already-added asset reveals its clip in the timeline (smooth minimal scroll to its time and lane; vertical-only in fit zoom). Verified by pointer-driving a real project: sticky ruler + gridline removal, no iframe remount on move/resize (marker survives, GSAP tween positions rewritten in place), duration readout 40->37->40 on shrink/stretch, and top-insert-band track creation renumbering lanes correctly on disk.
…e z/lane pipeline
Vertical clip moves committed in the store but never survived: two persist
bugs plus a runtime renumber all fought the stable-track-lanes model.
- timelineMoveAdapter deliberately stripped the track from lane-reorder
persists ('z-only reorder path' — the old z-driven lane model). Lane =
authored data-track-index now: lane-reorder and track-insert both persist
the track; plain timing moves omit it to stay SDK-fast-path eligible.
- Display lanes and file tracks are different coordinate spaces:
normalizeToZones packs sparse authored tracks (1,2,... or gaps, or DOM-index
fallbacks) onto contiguous display lanes, and lane edits persisted the LANE
number — silently re-targeting the wrong row in any non-0-contiguous file.
Elements now record their authoredTrack when remapped; a lane change
persists the target lane's authored track (store stays in lane space).
- The runtime split same-track clips of different kinds (video vs caption
div) onto separate renumbered tracks at discovery, so authored indices
never round-tripped ('drop onto an existing track' bounced back). Removed:
data-track-index is honored verbatim (render never reads it); kind-based
row presentation belongs in the display layer if ever wanted.
Adversarial review fixes on the same pipeline:
- runtime: parseInt(attr) || fallback dropped authored track 0 for GSAP and
overlay clips (parseAuthoredTrack helper honors 0)
- single-clip move fallback persisted only data-start — lane changes snapped
back on reload (now passes the track to the patch builder)
- lane-change z-sync candidate ignored a multi-selection's time shift, so
patches were computed against stale overlap sets
- track insert around a locked clip persisted a colliding renumber (the next
normalize merged lanes); the insert is now refused with a warning
- computeStackingPatches compared leaf z across CSS stacking contexts, where
ancestor z decides paint order; the sync now partitions by
stackingContextId and never patches across contexts
Timeline geometry (user-reported):
- fit zoom leaves 20% trailing headroom (FIT_ZOOM_HEADROOM in
timelineLayout.ts; single fit-pps source, so ruler/lanes/playhead/drag all
inherit it)
- playhead line center now sits exactly on GUTTER + t*pps at every zoom
(wrapper had shrink-wrapped to the 9px diamond, off-centering the line);
ruler ticks center on their timestamp
- ruler: frame-mode steps snap to whole frames (no duplicate labels), hour
steps added for far zoom-out, tick positions computed as exact multiples
(no float drift)
…ents An adversarial review of the canvas context-menu z-order pipeline (Bring to Front / Forward / Backward / Send to Back) found the resolver math sound but the glue between the menu and the commit hook broken: - The menu optimistically wrote style.zIndex AND position: relative to the live elements BEFORE the commit hook ran. The hook decides whether to persist position by checking getComputedStyle(el).position === 'static' — always false after the pre-apply — so the position patch was never persisted on the menu path and the reorder silently reverted at the post-commit reload for any nested/static element (root clips survive only because the runtime forces position:absolute). The same pre-apply made the failure rollback capture the already-mutated values, restoring the broken state on persist errors. The menu no longer pre-applies; the hook owns the live writes (it already applied both synchronously) and now sees true priors. Siblings without a persistable identity still get their z applied live-only so a renumber stays visually coherent. - The commit hook's entry.key store-sync plumbing had zero production callers; the store zIndex went stale until full reload. All three callers (canvas menu via PreviewOverlays, timeline lane z-sync, LayersPanel) now derive and pass the timeline store key (new deriveTimelineStoreKey helper). - patchElementBatch discarded the server's per-patch matched[]; unresolvable siblings persisted partially and silently. Unmatched targets now warn and report save-failure telemetry (z-reorder-unmatched) without rolling back the matched subset. - template/noscript elements counted as painting siblings, so renumber fallbacks wrote z-index/position into <template> tags in the source file. Excluded from the sibling family. - The default undo coalesce key merged DISTINCT z actions within 300ms into one undo entry; the action kind is now part of the key (LayersPanel drags keep coalescing within a drag; explicit lane-move gesture keys untouched). - rectsIntersect comment claimed touching rects intersect; the strict inequalities say otherwise — comment fixed.
Two legibility fixes for the canvas z-order menu, from user feel-testing: - z-only commits no longer remount the preview iframe. The commit hook already applies the inline z (+ injected position) to the live elements and updates the store synchronously; the post-commit reloadPreview() was a redundant full remount that read as a canvas 'blink' on every action. commitDomEditPatchBatches gains skipReload, engaged only when provably safe: every op is an inline-style patch AND the server reports every patch matched — anything else falls back to the reload so the preview reconverges with disk. The file-watcher's own reload stays suppressed by the existing domEditSaveTimestampRef window, so the skip is real. - Bring Forward / Send Backward step over the next VISIBLY overlapping sibling. The nearest z-neighbor in a composition is often invisible at the current frame (runtime hides time-inactive clips with inline visibility/display; GSAP parks elements at opacity 0), so the step crossed something the user couldn't see — 'enabled but nothing happens'. The forward/backward set now filters on element-level computed visibility (display/visibility/opacity, injectable for tests); enable/disable shares the resolver so the menu is honest: actions disable when no visible neighbor exists. Front/back keep the full painting family. - The neighbor that was stepped over gets a 600ms accent flash, drawn in the studio overlay layer (never in the iframe DOM), so the action shows its work.
Review 1 (restore commit): - asset reveal now clears any open preview overlay (stuck-overlay repro: preview on A, click already-added B — A stayed open over the reveal) - duration readout rolls back on failed persist: captureDurationRollback snapshots store + live root data-duration before the optimistic sync and restores both in every move/resize/delete/group catch (golden's previousDuration pattern) - asset preview opened during running playback dismisses immediately (the RAF loop bypasses the store, so the subscription alone never fired) - persistTimelineBatchEdit resolves the target (findTagByTarget) before treating identical output as a no-op — a mistargeted member now throws like the single-element path instead of being silently dropped - a post-mutation history-fold failure no longer suppresses the preview sync: fold errors are surfaced separately and the rewritten script still syncs (previously the preview kept stale GSAP positions with no recovery) - timelineRevealScroll guards degenerate viewports (windowSize <= 0) - CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches Review 2 (single-source-of-truth pass): - createTimelineElementFromManifestClip — the one manifest->element boundary — now carries authoredTrack and stackingContextId; expanded sub-comp children preserve both (authoredTrack in their OWN file's space) - authoredTrackForLane scopes occupants to the dragged clip's sourceFile (a foreign file's authored values are a different coordinate space); nearest-same-file-lane offset fallback - optimistic store updates mirror the persisted track into authoredTrack (and roll it back on failure), so consecutive drags before a reload resolve from fresh data - spill sub-lanes: documented decision — dropping onto a spill lane is a legitimate same-track join (occupants share the authored track by construction); false 'never a lane-move target' docstring rewritten - single-element fallback persists vertical-only moves (early return now requires neither start nor track changed; live DOM patch includes data-track-index) - canonical contextKey helper for stacking-context normalization - new pipeline test crosses the REAL factory boundary (sparse authored tracks -> factory -> expansion -> normalize -> drag commit -> persisted attribute), no injected fields
…predicate on preview open
|
Rebased as requested: the branch is now the six fix commits replayed linearly on top of current |
8013b40 to
512560b
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Rebased clean and linear on main (no merge commit), and commit 6 also closed both non-blocking follow-ups (delete-path rollback test + shared shouldDismissAssetPreview on open). Re-verified: commit 1's rebase conflict resolution correctly adapts to main's postRuntimeControlMessage refactor, commits 2-5 identical to the previously-approved head, commit 6 correct. CI green except the regression-shards still finishing (no failures). Re-approving.
…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.
…k 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>
What
Restores six timeline behaviors from the reviewed studio-dnd stack (
studio-dnd/consolidated-tip) that the Studio rebuild (#2291) dropped or regressed, fixes the staletimelineZones.tsthat #2279 introduced and #2291 never repaired, and adds two small sidebar-asset UX improvements.Why
A golden-branch-vs-main comparison after the Studio cutover found five verified regressions behind user-visible symptoms, plus one new bug found by pointer-driving the result:
086fdabda) but not itsTimelineRuler.tsx/PlayerControls.tsxhunks: main kept the pre-refresh ruler that draws full-height gridlines (visible in the gaps above/below opaque tracks) and scrolls away vertically.timelineZones.ts— main still ran feat(studio): timeline collision and placement model #2279's z-driven lane pack, while its owntimelineClipDragCommit.ts— main's post-feat(studio): revamps Studio + improves code quality #2291 rewrite, which kept golden'snormalizeToZonescall contract (correction per review: the file itself equals main's, not golden's) — has insert-band commits that contractually depend on the golden track-drivennormalizeToZones(lane = authoreddata-track-index; z = paint order only).persistTimelineBatchEdittreated any no-op batch member (attributes already at target values, which a track-insert renumber legitimately produces) as a fatal targeting error, aborting and rolling back the whole insert. The golden batch patcher skips no-op members (timelineElementsMove.ts:116).scriptTextand calledreloadPreview()(a full iframe remount) unconditionally; golden soft-reloads the script in place and full-reloads only when it can't.readClipZIndexwas reverted to fabricate0for unresolvable clips whiletimelineStackingSyncstill implements the goldenNumber.isFiniteexclusion contract, skewing z-boundary math.How
TimelineRuler.tsxadopted from golden (stickytop-0, beat-lines-only background, frame labels);timeDisplayModelifted intoplayerStore(persisted via studio UI preferences);PlayerControlstoggle store-backed (also drops the transport-barborderTop, a cosmetic hunk of the same golden visual-refresh commit — disclosed per review).timelineZones.ts+ test replaced with the golden stable-track-lanes version;timelineClipDragCommit.test.tsupdated surgically (main-only optimistic-revision coverage kept).persistTimelineBatchEditskips no-op members and returns early when nothing changed; regression tests added.hooks/timelineTimingSync.ts(extracted to stay under the 600-line gate):GsapMutationStatuscarriesscriptText,syncTimingEditPreviewsoft-reloads viaapplySoftReload, group edits soft-reload only when every change targets the active comp (one full reload otherwise). The server already returnedscriptText; the client had discarded it.setCompositionDurationToContent(furthestClipEndFromSource(...))(grow-or-shrink);syncPreviewContentDurationupdates the store + live rootdata-durationsynchronously at release; delete shrinks too. Main's optimistic-revision rollback, operation tags, PostHog commit events, andrunGestureTransactioncanvas paths are untouched.timelineAssetDrop.tsrestored to golden: drops land on the drop track (no overlap bump to max-track+1),data-hf-idstamped, audio getsdata-volume.timelineRevealScrollmath +clipRevealRequeststore channel; vertical-only in fit zoom).Test plan
telemetry/*+SnapToolbarfail identically on pristine main (Node-26 localStorage environment issue)tsc --noEmitclean; oxlint/oxfmt clean; full workspace build green; all pre-commit gates (filesize, fallow, lint, format, typecheck) pass