Skip to content

fix(studio): a row move restacks only the clip you moved, so nothing else goes behind - #5057

Merged
miguel-heygen merged 4 commits into
mainfrom
dlayers/stack-moved-clip-only
Oct 5, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
dlayers/stack-moved-clip-only

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

Moving a clip to another timeline row now restacks only that clip:

  • Moved up, it rises above the clips it overlaps in time on the rows now below it.
  • Moved down, it sinks below the clips it overlaps on the rows now above it, but never below z-index 0, and never under a clip on a lower row that it draws over now.
  • Every other clip keeps its z-index, including other selected clips that only shifted in time.

Why

Until now a row move made the whole overlapping stack follow row order, and lifted clips the person never touched. Films often put a full-frame scene or background on the top row, with captions and overlays on lower rows. In such a film, dragging a caption up one row (still under the scene's row) lifted the scene over it, and the caption disappeared.

Repro on main:

  1. Make a film with a full-frame scene on track 0 and a caption on track 3 with z-index: 1, overlapping in time.
  2. Drag the caption up to track 2.
  3. The file now gives the caption z-index 0 and the scene 1, and the preview shows only the scene.

Moving a clip to the top row still brings it in front of everything it overlaps.

Related work

Refs #4937, which already records a group move as one undo step until the following edit, so the row change and this restack undo with one Cmd+Z.

How

  • computeStackingPatches(elements, editedKeys, direction) takes the move's direction.
    • Up: the moved clip goes to one above the highest overlapping clip on a lower row, only if it does not already paint above them all.
    • Down: it goes to one below the lowest overlapping clip on a higher row, clamped at 0.
  • The neighbour cascade (resolveEditedZ, trySingleZ, liftAbove and two helpers) is deleted.
  • syncStackingForEdit passes aimedLane < currentLane ? "up" : "down". It already received both lanes.
  • It restacks only the dragged clip. In a multi-selection drag the other selected clips keep their rows and only shift in time, so they keep their z-index. On main they were restacked too.
  • Moved down, the clip stops at the lower-row clips it draws over now. Without that, a move that z 0 cannot complete would drop the clip under a clip on the row below and hide it.
  • z-index stays at 0 or above. A negative z can paint behind the composition root's own background, which would hide the clip.
  • Known limit: a clip moved down under an overlapping clip that is also at z 0 and earlier in the file drops to 0 but still paints above it. Only moving that neighbour or going negative could put it behind, and either can hide a clip. Films with no authored z-index hit this on most downward moves.

Test plan

  • Unit tests added/updated. timelineStackingSync.test.ts gives every case a direction:
    • New: the scene-on-track-0 trace, "neighbours never move", the z-0 floor and the downward multi-clip order.
    • Against main's implementation, 8 of the new cases fail, including the scene case.
  • The host test in timelineStackingSyncExport.test.tsx now seeds z-index values a downward move can express. It still proves that a row move restacks the host's preview.
  • New regression tests: a multi-selection drag patches only the dragged clip; a blocked move down writes nothing; a move down stops at the lower-row clips. All three fail against the previous head.
  • The touched test files plus the Layers panel tests passed 3 runs in a row (115 tests), with typecheck, oxfmt, oxlint and the comment ratchet green. The full Studio suite failed only that host test before it was updated (7510 passed).
  • Manual testing performed: the Studio dev server, the repro film above, Before and After below.
  • Comments follow CONTRIBUTING.md "Comments".

Review

Independent adversarial review, at head 4b40d5b:

  • First pass, at 400bbe1: two MAJORs, both fixed at 9730975 (the multi-selection restack, and the floor that could hide a clip moved down). The delta review then reproduced both scenarios with no patch written.
  • No BLOCKERs and no MAJORs remain.
  • MINOR: the "same z, later in the file" branch of the down-move floor has no test of its own. A wrong value there would only make the clip sink one step less.
  • MINOR: computeStackingPatches still accepts several edited keys, and its only caller now passes one.
  • MINOR, by design: a clip moved up rises above the clips now below it even if that takes it over a clip on a higher row. Capping it would make "move to a higher row" do nothing in films with no authored z-index.
  • Not exercised: a real pointer drag in Studio beyond the captures below.

Before

main: the caption, dragged up from the fourth row to the third (still under the scene's top row), is no longer drawn. The file now gives the scene z-index: 1 and the caption z-index: 0.

Before: on main the caption disappears behind the full-frame scene after moving up one row

After

This branch: the same drag leaves both z-index values as they were, and the caption stays in front of the scene.

After: the caption stays in front of the scene after the same move

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 2040 (base branch 2040), smooth 1648 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

…else goes behind

Moved up, the clip rises above the clips it overlaps on the rows now below it; moved down, it
sinks below the ones now above it, never under z-index 0. Neighbours keep their z, so a
full-frame scene on the top row no longer jumps over a caption moved up a row beneath it.
Drops the stale cascade wording, checks the time overlap before the paint-scope string compare,
and every test case names its move direction.
@miguel-heygen
miguel-heygen force-pushed the dlayers/stack-moved-clip-only branch from b0ed462 to 400bbe1 Compare October 5, 2026 09:56
…n never hides it

Selected clips that only shifted in time keep their z-index. A move down stops at the clips on
lower rows it paints over, instead of dropping under them when z-index 0 cannot get it behind
the clip above.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 11:23

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

Review at 4b40d5b3. Approving.

The fix. With raisedZ/loweredZ, the moved clip is the only one patched, so a clip nobody touched can't change z. The scene-on-track-0 case writes nothing. A move up returns null when the clip already paints above every clip that's now below it. A move down is clamped at 0, and it stops at the lower-row clips it draws over. Both match the description. Restacking only dragKey in a multi-selection drag is right: the other selected clips only shift in time, so they keep their rows and their z.

Tests. I ran src/player plus timelineStackingSyncExport.test.tsx three times, and 2209 tests passed each time. I then made one change at a time to the logic and ran the three touched test files (96 tests). 8 of the 9 changes are caught:

  • the up no-op check
  • the down no-op check
  • the z 0 floor
  • dropping keepOver
  • the z < clip.zIndex gate
  • the order of edited clips
  • flipping the direction
  • always passing "up"

The one that survives is the equal-z tie branch in the floor (? o.zIndex : o.zIndex + 1), which the PR already lists as untested.

CI has 37 checks passing and 0 failing, with 21 still running when I posted.

Reuse / simplification. This deletes the neighbour cascade and reuses spansOverlap and samePaintScope, so net it's −205 lines. One more cut is available: the only caller now passes one key, so computeStackingPatches could take a single key instead of Iterable<string>. That drops the direction sort and the multi-clip ordering test, which no production path reaches any more. It's optional.

Non-blocking, a latent fragility:

  • syncStackingForEdit gets the direction from aimedLane < currentLane.
  • On the track-insert path, aimedLane is drag.insertRow, a boundary index into trackOrder, while currentLane is a track value.
  • They agree today only because normalizeToZones packs the visual lanes to 0..n-1.
  • With a row whose track isn't its index, they disagree.

I checked this with a legacy expanded sub-composition row (trackOrder: [0, 0.25, 1, 2]). A caption on track 2 (z 2) was drawn over a scene on track 0 (z 1). I dragged the caption into a new row directly above its own row. That gesture was classified as "down" and wrote {a: 0}, which put the caption under the scene. The same gesture without the extra row writes nothing.

Current rows never set expandedHostKey, so I don't think a user can hit this today. Even so, comparing the candidate's own target track would remove the dependency:

const target = candidate.find((e) => keyOf(e) === dragKey)?.track;
// direction: target < currentLane ? "up" : "down"

That also lets the aimedLane === currentLane guard compare like with like.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit ff8ad1c Oct 5, 2026
136 of 137 checks passed
@miguel-heygen
miguel-heygen deleted the dlayers/stack-moved-clip-only branch October 5, 2026 12:23
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