Skip to content

feat(studio): add variable timeline timing and layout#2782

Open
miguel-heygen wants to merge 1 commit into
codex/studio-timeline-b-keyframe-state-v2from
codex/studio-timeline-b-variable-layout-v2
Open

feat(studio): add variable timeline timing and layout#2782
miguel-heygen wants to merge 1 commit into
codex/studio-timeline-b-keyframe-state-v2from
codex/studio-timeline-b-variable-layout-v2

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Adds variable timeline timing and the shared geometry/layout model used by keyframe lanes.

Why

Keyframe placement must be derived consistently from clip-local timing and the visible timeline scale; duplicating that math across components causes drift and incorrect seeking.

How

Centralizes timing conversion and lane geometry behind focused helpers and tests. This is B2 of the Family B draft Graphite stack.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (not applicable)

Exact Family B tip validation: 2,865 Studio tests, 67 Studio Server route tests, both package typechecks, formatting, lint, diff check, file-size gate, and Fallow audit passed.

Deferred review findings

Every blocker and high finding raised on this PR is fixed in the stack. The 5 remaining low/nit findings are parked, verbatim, in .scratch/studio-timeline-family-b/issues/02-pr-2684-deferred-review-findings.md:

  • 🟡 packages/studio/src/player/components/timelineMarquee.ts:71 — Marquee selection is now O(clips × rowHeights) — offsets recomputed per clip
  • 🟡 packages/studio/src/player/components/timelineClipDragPreview.ts:135 — originRow uses the CURRENT bounding-rect top even though it references the ORIGIN pointer/scroll
  • 🟢 packages/studio/src/player/components/timelineLayout.ts:121 — getTimelineRowFromY beyond last row divides by TRACK_H even when the last row is expanded
  • 🟢 packages/studio/src/player/components/timelineMarquee.ts:71 — getTimelineClipRect uses full row height — marquee hit-boxes for clips in expanded rows extend under property lanes
  • 🟢 packages/studio/src/hooks/timelineMoveAdapter.ts:7 — TimelineMoveEdit no longer allows stackingReorder — silent drop in group moves

Supersedes #2684, which was closed when main was rewritten to unwind an early landing of this stack. Same head commit, same review history.

R1 review follow-ups

No high findings. The 3 low findings (edge track-create threshold scaling with row-0 height, .finally invalidation on rejection, trackHeights() with no production consumer) are parked in .scratch/studio-timeline-family-b/issues/09-family-b-v2-r1-deferred.md.

miguel-heygen commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

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

COMMENT — R1 adversarial pass (--tier max, head 514a219).

Foundation for a variable-height timeline. Row-geometry helpers (trackHeights, getTimelineRowOffsets, getTimelineRowFromY/getTimelineRowPositionFromY, getTimelineInsertBoundaryBand) are well factored, backward-preserving when rowHeights = [], and covered by both variable-row unit tests and collapsed-row characterization tests. SDK + fallback move/resize paths now converge on the same GSAP resync + cache invalidation — a real symmetry win. No blockers; three low-severity observations.

Adversarial lenses

(Lens A — timing determinism, Lens B — layout coord, Lens C — hit-testing, Lens D — resize/responsive, Lens E — #2781 state consistency.)

  • Lens C — track-create threshold now scales with the row-0 pixel height. packages/studio/src/player/components/timelineClipDragPreview.ts:132-166 feeds resolveTimelineMove with trackHeight: 1 and row-float coordinates. EDGE_TRACK_CREATE_THRESHOLD = 0.55 (timelineEditing.ts:41) is now measured in row-index units, so on a collapsed first row it stays at 0.55 × 48 ≈ 26 px (parity), but on an expanded first row (TRACK_H + 2·LANE_H = 104 px) the same 0.55 threshold requires ~57 px above the top gutter before a new top track spawns. Same story below the last lane except that path falls back to TRACK_H in getTimelineRowOffset — so top-edge creation scales with row-0 height, bottom-edge creation stays fixed. Likely intended (bigger row → farther travel), but worth an explicit muscle-memory decision in a follow-up before the wiring PR ships. P3 / nit.
  • Lens A — .finally(() => invalidateGsapCache?.()) fires even on rejection. packages/studio/src/hooks/useTimelineEditing.ts:186-215 and :294-329 bump the GSAP cache regardless of whether finishClipTimingFallback succeeded. If the server rewrite failed but the DOM/store still holds an optimistic delta, a cache bump forces the animation parser to re-read whatever is on disk. In practice this is likely benign (parser sees stale server state, animation looks correct-ish), but the intent of invalidateGsapCache was "the server just rewrote positions." Consider .then(...) for the success-only invalidation, or leave a comment saying failure-path invalidation is deliberate. P3 / nit.
  • Lens E — trackHeights() exported without a production consumer. GitHub code search finds no callers outside tests at 514a219. Wiring lands in a later Family-B PR — acceptable for B2 — but flag on the merge-order plan so it doesn't sit dormant if the stack is cherry-picked. P3 / nit.

Positive callouts: INSERT_BOUNDARY_BAND is now geometry-exact per row (CLIP_Y / rowHeight), so an expanded row's insert band shrinks in row-fraction while staying at 3 px in absolute — the anti-phantom-insert guard survives variable heights. The originRow / currentRow refactor with trackHeight: 1 is a clever way to route variable-pixel drags through the existing threshold logic without touching resolveTimelineMove. getTimelineClipRect and computeMarqueeSelection grew optional params with backward-compatible defaults, so the sole external caller (useTimelineRangeSelection.ts) needs no change.

— Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at 514a219433d1.

R2 adversarial pass on the variable-timeline timing + shared geometry slice. Cluster of concerns around cache-invalidation policy asymmetry (single-clip .finally vs group post-await), row-extrapolation asymmetry past first/last row, and one closure-instability item that cascades through the TimelineEditContext.

🟢 Verified clean

  • timelineMoveAdapter.ts — TimelineMoveEdit / TimelineMoveEditsHandler exports match pre-existing anonymous shape used at App.tsx and TimelinePane.tsx
  • useTimelineClipDrag.ts — rowHeightsRef threaded through as optional prop, callback deps updated correctly
  • timelineCollision.ts — replacing local INSERT_BAND=3/48 with imported INSERT_BOUNDARY_BAND=CLIP_Y/TRACK_H preserves numeric default
  • timelineMarquee.ts — contentOrigin default (GUTTER + TRACKS_LEFT_PAD) matches previous hardcoded origin; rowHeights default of [] preserves collapsed-row math

Complements Via's parallel adversarial pass (Family B rollup). Where Via found 0 P1 / 17 P2 / 18 P3, the adversarial subagent pass here surfaced additional depth on the mutation-authority thread and the Promise chain.

Review by Rames D Jusso

sdkSession: editFlowSdkSession,
publishSdkSession: sdkHandle.publish,
forceReloadSdkSession: sdkHandle.forceReload,
invalidateGsapCache: () => invalidateGsapCacheRef.current(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 invalidateGsapCache prop is a fresh closure every render, defeating downstream memoization

invalidateGsapCache: () => invalidateGsapCacheRef.current(),

This arrow is recreated on every StudioApp render. Threaded into useTimelineEditing/useTimelineGroupEditing as a dependency of the useCallback wrapping handleTimelineElementMove/Resize (useTimelineEditing.ts:259 and :359 list invalidateGsapCache in the deps array), so every unrelated re-render invalidates those memoized handlers, which then invalidates handleTimelineElementsMove useCallback in App.tsx:180-186, which invalidates the whole TimelineEditContext value cascade. invalidateGsapCacheRef exists precisely so it can be a stable target.

Fix: Wrap the arrow with useCallback with no deps, or pass invalidateGsapCacheRef and destructure .current() inside the hook.

Review by Rames D Jusso

const offsets = getTimelineRowOffsets(rowHeights);
if (row <= 0) return row * getTimelineRowHeight(0, rowHeights);
if (row >= rowHeights.length) {
return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Row-extrapolation past the last row uses TRACK_H instead of the last row's height (asymmetric with pre-first-row branch)

if (row >= rowHeights.length) {
  return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H;
}

Symmetric pre-first-row branch four lines above uses row 0's actual height (row * getTimelineRowHeight(0, rowHeights)), so a pointer above the top pad extrapolates with the first row's concrete height. But past-last always divides by TRACK_H, and getTimelineRowFromY at line 137 has the same TRACK_H fallback. When bottom track is expanded, a pointer in the bottom breathing pad maps to a fractional row that doesn't match top-pad extrapolation-scale. getDefaultDroppedTrack and resolveInsertRow at trackCount are integer-clamped so unlikely to change a landing decision today, but the two branches should be symmetric to avoid subtle drift.

Fix: Extrapolate past the last row using getTimelineRowHeight(rowHeights.length - 1, rowHeights) (mirroring pre-first-row branch), or standardize both branches on TRACK_H — but pick one and document.

Review by Rames D Jusso

coalesceKey,
recordEdit,
edit: { kind: "shift", delta: updates.start - element.start },
}).finally(() => invalidateGsapCache?.());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Single-clip vs group timing writers invalidate GSAP cache at different points on failure

}).finally(() => invalidateGsapCache?.());

Single-clip finishMoveGsapSync/finishResizeGsapSync chain the invalidation in .finally() on finishClipTimingFallback (lines 203 and 312) — cache invalidated even when GSAP mutation rejects. Group path in useTimelineGroupEditing.ts:333 and :440 does invalidateGsapCache?.(); on the line AFTER await finishGroupTimingGsapFallback(...) — invalidation is skipped when the fallback rejects. Also, SDK single-clip path only calls invalidateGsapCache via finishMoveGsapSync (line 234), so an SDK persist that throws before finishMoveGsapSync runs never invalidates. Pick one policy and apply consistently.

Fix: Either move group invalidation into a try/finally that always runs after SDK+fallback sequence, or drop single-clip .finally and only invalidate on committed success — but match the two paths.

Review by Rames D Jusso

currentScrollTop: 0,
pixelsPerSecond: pps,
trackHeight: TRACK_H,
trackHeight: 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 resolveTimelineMove trackDeltaRaw is now piecewise row-space, changing stacking sensitivity in expanded rows

  trackHeight: 1,
  maxStart: dragMaxStart,
  trackOrder,
},
clientX,
currentRow,
);

Previously trackDeltaRaw = (clientY - originClientY + scrollΔ) / TRACK_H, a linear pixel-to-row measure. Now originRow/currentRow are computed through getTimelineRowFromY(..., ctx.rowHeights) and input.trackHeight is 1, so trackDeltaRaw = currentRow - originRow, a piecewise-linear quantity. Fine for Math.round(trackDeltaRaw) integer track-crossing logic (a taller row physically requires more pixels to cross — arguably correct). BUT same trackDeltaRaw flows unchanged into resolveTimelineLayerStackingMove (timelineEditing.ts:144, timelineLayerDrag.ts:362: const targetPosition = currentIndex + input.trackDeltaRaw;) whose thresholds (ONTO_ROW_THRESHOLD, Math.round(targetPosition), Math.ceil(targetPosition)) were tuned against uniform-row assumption. In an expanded row, pixel travel required to trigger stacking reorder is now noticeably larger. No test covers stacking with variable rowHeights.

Fix: Add a stacking-in-expanded-row test that pins the intended pixel/threshold contract, or thread caller's active-row height back into stacking so thresholds remain expressible in pixels rather than piecewise row-fractions.

Review by Rames D Jusso

return shiftGsapPositions(projectId, changePath, domId, delta);
},
});
invalidateGsapCache?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Group-mode invalidateGsapCache runs after every non-trackOnly batch — including audio-only or zero-delta shifts producing no GSAP mutations

  invalidateGsapCache?.();
}).catch((error) => {

The if (trackOnly) return; early-return above only gates on batch having no track change; a mixed batch where every timing delta is 0 (e.g. a group where only one clip has a real start delta and others resolved to δ=0) or where every change is an audio clip (no GSAP animations) still hits invalidateGsapCache?.(). mutateChange callback returns null for delta===0 or missing domId, so those clips issue no server mutation, yet the whole GSAP cache is invalidated anyway. Not a correctness bug — parity with single-clip path (finishClipTimingFallback gates on timingChanged && domId && projectId) is broken.

Fix: Only invalidate when at least one mutateChange actually returned a non-null promise, or when at least one animated change in the batch.

Review by Rames D Jusso

contentY: number,
rowHeights: readonly number[] = [],
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
const rowFloat = getTimelineRowFromY(contentY, rowHeights);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 getTimelineRowPositionFromY recomputes offsets internally, doubling piecewise scan on every pointermove

export function getTimelineRowPositionFromY(
  contentY: number,
  rowHeights: readonly number[] = [],
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
  const rowFloat = getTimelineRowFromY(contentY, rowHeights);
  const row = Math.floor(rowFloat);
  return {
    rowFloat,
    row,
    fraction: rowFloat - row,
    rowHeight: getTimelineRowHeight(row, rowHeights),
  };
}

getTimelineRowFromY builds getTimelineRowOffsets(rowHeights) from scratch on every call (line 126 allocates offsets array + linear scan). computeDragPreview invokes getTimelineRowFromY twice per pointermove (origin + current) at lines 137/141, then resolveDropPlacement invokes getTimelineRowPositionFromY which invokes it a third time. For a 3-tick pointermove burst that's 3× (allocate array + scan). Trivial today but scales with track count and drag rate.

Fix: Compute offsets array once per pointermove (or memoize behind rowHeightsRef identity) and pass it through row math instead of rebuilding on each helper call.

Review by Rames D Jusso

coalesceKey,
recordEdit,
edit: { kind: "shift", delta: updates.start - element.start },
}).finally(() => invalidateGsapCache?.());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 invalidateGsapCache is passed as an unconditional arrow but callers still use ?.()

.finally(() => invalidateGsapCache?.());

Prop is optional on both hook types (useTimelineEditingTypes.ts line 50 and useTimelineGroupEditing.ts line 57 mark it ?), but only production caller — App.tsx line 177 — always passes a defined arrow. Either option-typing is intentional (in which case ref-based indirection is wrong shape — should be stable useCallback) or it isn't, in which case ?.() guards are dead code. Choose one for clarity.

Fix: Make invalidateGsapCache required (dropping the ?.), or actually make the prop optional in App.tsx by not passing when DOM edit session hasn't populated the ref yet.

Review by Rames D Jusso


/** Fractional insert band for the concrete row under a pointer. */
export function getTimelineInsertBoundaryBand(rowHeight: number): number {
return CLIP_Y / validRowHeight(rowHeight);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ getTimelineInsertBoundaryBand has no direct unit test; only exercised via drag-preview integration

export function getTimelineInsertBoundaryBand(rowHeight: number): number {
  return CLIP_Y / validRowHeight(rowHeight);
}

New helper is the only production hook keeping resolveInsertRow's band aligned with rendered clip inset when rows are variable-height. Exported but not covered by any test in timelineLayout.test.ts — only exercised via two new expanded-row integration tests in timelineClipDragPreview.test.ts (149-193). Direct unit test would prevent regressions where a future edit changes CLIP_Y or validRowHeight fallbacks without updating the band.

Fix: Add a small dedicated test asserting the band for TRACK_H, expanded (TRACK_H + n*LANE_H), and invalid inputs (0/NaN/undefined) all reduce to intended clip-inset fraction.

Review by Rames D Jusso

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-variable-layout-v2 branch from 514a219 to 97dd041 Compare July 25, 2026 21:17
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.

3 participants