Skip to content

feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order)#2380

Open
ukimsanov wants to merge 5 commits into
mainfrom
feat/track-paint-order-mirror
Open

feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order)#2380
ukimsanov wants to merge 5 commits into
mainfrom
feat/track-paint-order-mirror

Conversation

@ukimsanov

Copy link
Copy Markdown
Collaborator

What

Implements the agreed model: track order = default paint order, authored z = advanced override. Canvas z-order menu actions (Bring to Front / Bring Forward / Send Backward / Send to Back) on a timeline clip now ALSO move the clip on the timeline — to the closest track beyond the element it crossed, in the action's direction, that is free over the clip's whole time span; when none is free, a new track is created adjacent to that element (the sanctioned insert renumber). Both writes fold into one undo entry. Clips whose authored z contradicts their lane order get a small "z" badge ("Paint order overridden — see Layers panel"), so overrides are surfaced instead of silently disagreeing with the timeline.

Design (the load-bearing decisions)

  • Render truth stays z. The runtime/renderer is untouched — compositions remain plain HTML where z-index means what CSS says. The studio maintains z↔track consistency at edit time: vertical timeline drags already sync lane→z (shipped in fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild #2347); this PR adds the missing direction, z-action→lane.
  • Mirror rule: target = closest free lane strictly beyond the crossed neighbor (forward/backward) or beyond the overlap set's extreme (front/back); freeness = the clip's entire [start, end) span; no free lane → insert a new one at the boundary beyond the reference. Same-stacking-context / same-file scoping matches the menu's existing sibling scope, so sub-comp children mirror within their own file's lanes and cross-context cases never arise. Non-clip decorations (no timeline presence) keep the previous z-only behavior. Audio zone untouched.
  • ⚠️ Open product question (M/Bin sign-off wanted): the comparison scope is temporal-overlap — "send to back" goes below the lowest clip it coexists with in time, not below every visual track on the timeline. Less timeline churn; trivially changeable to whole-timeline scope if preferred. Default documented in timelineZMirror.ts's module note.

How

  • timelineZMirror.ts — pure resolver ({kind:"move", displayTrack, persistTrack} | {kind:"insert", insertRow} | null), reusing isLaneFree/timeRangesOverlap and the shared authored-space translation (authoredTrackForLane) — no duplicated machinery. 31 dedicated tests (span-freeness, zone boundary, sparse authored files, sub-comp scoping, determinism).
  • useCanvasZOrderTimelineMirror.ts — runs after the z commit resolves; kind:"move" persists through the same path as a timeline lane drag (optimistic store update, authoredTrack refresh, rollback); kind:"insert" reuses commitTrackInsert's renumber via a shared buildTrackInsertEdits core. The mirror never triggers the lane→z stacking sync, so it cannot fight the z values the action just set (tested).
  • One undo entry: zReorderCoalesceKey is per-gesture-unique (monotonic seq) and both records carry coalesceMs: Infinity — a gesture always folds regardless of persist latency, and distinct gestures can never merge. This also hardens the existing lane-drag move→z fold, which had the same latent >300ms split. The fold test simulates a 400ms gap (failed before, passes after).
  • timelineZOverride.ts + TimelineClip badge — laneIsAbove XOR paintsAbove among temporally-overlapping same-context visual neighbors, using the exact stacking-sync predicates (exported, not copied).

Test plan

  • Unit: 31 resolver tests, mirror wiring (move / insert / null / no-stacking-sync-retrigger / undo fold incl. slow-persist gap / two-gestures-two-entries), badge rule (9), insert-core parity — full studio suite 2161 passing (the 18 telemetry/SnapToolbar failures are the known Node-localStorage environment set, identical on pristine main)
  • All pre-commit gates green (filesize, fallow, lint, format, typecheck)
  • Live pointer-driven verification on a real project: Bring-to-front wrote z AND created a new top track with the correct renumber on disk (no free lane above, both overlapping clips' lanes occupied); Send-to-back correctly stayed z-only (nothing overlaps the clip below in time — the temporal-overlap scope working as designed); the "z" badge renders on clips whose authored z genuinely contradicts lane order; ONE ⌘Z reverts the z write + lane hop + renumber atomically (zero file diff vs the pre-action snapshot)

…e 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).
…s 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.
…ack 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.
@ukimsanov

Copy link
Copy Markdown
Collaborator Author

Follow-up commit from live feel-testing:

  • Flashless mirror: the mirrored lane write rode the timing-edit fallback, which full-reloads the preview when there's no GSAP script to soft-swap — but track-only batches change nothing the renderer reads, so they now skip the GSAP round-trip and the reload entirely (single moves AND insert renumbers). Live-verified with an iframe marker surviving the whole gesture.
  • Icons on the four z-order actions (layer-diamond + arrow set; pierced stack for Front/Back). Labels unchanged — they're the standard names across PowerPoint/Illustrator/Figma.
  • New: Close gap / Close all gaps on right-clicking empty lane space (CapCut/Premiere-style, track-scoped, leading gaps included, one undo per action, refuses around locked clips, gap width shown in the menu). Pure math module + 33 tests; persists via the existing atomic batch path. Live-verified: 22.51s gap closed flush on disk, one ⌘Z restored it.

Full studio suite 2196 passing (same 18 environmental failures); all pre-commit gates green.

…ssical 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.
@ukimsanov

Copy link
Copy Markdown
Collaborator Author

Two more feel-test rounds (954bcf6):

  • Menu order → classical: Bring to Front, Bring Forward, Send Backward, Send to Back (with the layer/arrow icons added in the previous commit).
  • Gap-close blink, properly killed. Journey worth documenting: v1 soft-reloaded by re-running the comp's single inline script — bailed on multi-script comps (most real ones). v2 re-ran ALL inline scripts in document order — passed 2210 unit tests, then live testing on a three.js-heavy composition showed it re-executing scene-init scripts (doubled init warnings) and falling back to a reload anyway. v3 is the correct model: when mutated === false the existing __timelines are still valid and only the runtime's visibility windows are stale, so the sync now runs the soft-reload finalization only (seek + __hfForceTimelineRebind + manual-edits reapply) with zero script execution. Bonus: comps with no GSAP script at all — which previously always remounted — are now flashless too.
  • Live-verified on the three.js composition: Close-all-gaps shifted 4 caption clips with correct cumulative amounts (−2.43s / −9.00s across two gaps), the preview iframe never remounted (marker survived), one ⌘Z reverted everything byte-exact.

Suite 2210 passing (same 18 environmental), all gates green.

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.
@ukimsanov

Copy link
Copy Markdown
Collaborator Author

Final semantic correction from user review (bf09849): forward/backward are a one-element step, bounded. The mirror's lane target is now capped at the next temporally-overlapping element beyond the crossed neighbor — free lane strictly between them if one exists, otherwise a new track inserted immediately beyond the crossed element, never traveling past the second one (the old closest-free-lane search could overshoot, creating a track/paint contradiction the zOverride badge would flag on our own action). Front/back keep whole-set semantics (send-to-back lands at the bottom of the visual zone, above audio). The 3-stacked back-to-back case is pinned end-to-end: resolver → commitZMirrorLaneMove → persisted renumbered tracks strictly between the two neighbors. 82 targeted tests, suite 2218 passing (same 18 environmental), all gates green.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on the current head (bf09849). The decomposition and test effort are strong, but these verified correctness and persistence issues can leave Studio state or project files inconsistent:

  1. timelineClipDragCommit.ts:404 — mirror inserts normalize the full timeline and can persist track renumbers into unrelated composition files (and some layouts shift audio). Scope the insert candidate and writes to the selected source-file visual lane family.
  2. timelineZMirror.ts:118sameSourceFile is treated as the stacking-context boundary. A file can contain multiple CSS stacking contexts; use the canonical stackingContextId partition.
  3. useCanvasZOrderTimelineMirror.ts:76 — the crossed-neighbor key omits selectorIndex, so duplicate class selectors resolve to index 0 and can mirror against the wrong clip.
  4. timelineGaps.ts:86 — gap compaction starts at absolute time 0 even for expanded children whose display time is parent-relative. This can move an expanded clip to the wrong display time and persist a different local time.
  5. PreviewOverlays.tsx:254 — an unmatched/no-change z patch resolves successfully and still triggers the lane mirror. Return explicit match/persist status and mirror only after the selected z patch is durable.
  6. timelineTimingSync.ts:450 — multi-clip gap closing performs sequential GSAP mutations. If a later mutation fails, earlier script rewrites remain on disk without the aggregate history fold. Make the mutation atomic or roll back all touched files.
  7. PreviewOverlays.tsx:254 — z persistence and lane mirroring use separate queues. A second rapid gesture can write between the first gesture phases and overwrite file state; serialize the complete z-to-lane transaction.

Please add regression coverage for mixed source files/stacking contexts, duplicate selectors, expanded-lane origins, unmatched patches, late GSAP failure, and overlapping z gestures.

All current CI checks are green; these are behavioral cases not covered by the existing suite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants