fix(studio): restore golden-branch timeline behaviors dropped by the 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.
This commit is contained in:
ukimsanov
2026-07-13 16:48:51 -07:00
parent d04bcbf7c4
commit 54f41b41b6
25 changed files with 1752 additions and 1394 deletions
@@ -6,17 +6,19 @@ import {
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
extendRootDurationIfNeeded,
finishTimelineTimingFallback,
foldGsapMutationIntoHistory,
formatTimelineAttributeNumber,
patchIframeDomTiming,
persistTimelineBatchEdit,
readFileContent,
scaleGsapPositions,
shiftGsapPositions,
type PersistTimelineBatchChange,
type RecordEditInput,
} from "./timelineEditingHelpers";
import {
finishGroupTimingGsapFallback,
readFileContent,
scaleGsapPositions,
shiftGsapPositions,
syncPreviewContentDuration,
} from "./timelineTimingSync";
export interface TimelineGroupMoveChange {
element: TimelineElement;
@@ -149,6 +151,55 @@ export function useTimelineGroupEditing({
],
);
// Shared SDK fast path for group move/resize: eligible when nothing needs the
// server (no root-duration growth, one shared file, every change SDK-addressable
// and `eligible` per the caller's own gate). Returns whether the SDK handled it;
// false → caller falls through to the server batch persist.
const trySdkBatchPersist = useCallback(
async (input: {
changes: readonly { element: TimelineElement }[];
sdkChanges: Array<{
hfId: string;
timingUpdate: { start: number; duration?: number };
} | null>;
eligible: boolean;
needsExtension: boolean;
label: string;
coalesceKey: string;
}): Promise<boolean> => {
const sharedPath = allChangesSharePath(input.changes, activeCompPath);
const canUseSdk =
!input.needsExtension &&
sharedPath !== null &&
input.eligible &&
input.sdkChanges.every((change) => change !== null);
if (!canUseSdk) return false;
return sdkTimingBatchPersist(
input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: input.label, coalesceKey: input.coalesceKey },
);
},
[
activeCompPath,
domEditSaveTimestampRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
],
);
const handleTimelineGroupMove = useCallback(
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
if (changes.length === 0) return Promise.resolve();
@@ -163,38 +214,28 @@ export function useTimelineGroupEditing({
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(maxEnd);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
: null,
);
const canUseSdk =
!needsExtension &&
sharedPath !== null &&
changes.every((change) => change.track == null) &&
sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Move timeline clips", coalesceKey },
);
if (handled) return;
}
const handledBySdk = await trySdkBatchPersist({
changes,
sdkChanges: changes.map((change) =>
change.element.hfId
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
: null,
),
eligible: changes.every((change) => change.track == null),
needsExtension,
label: "Move timeline clips",
coalesceKey,
});
if (handledBySdk) return;
await persistServerBatch(
projectId,
@@ -212,50 +253,34 @@ export function useTimelineGroupEditing({
})),
coalesceKey,
);
await finishTimelineTimingFallback({
await finishGroupTimingGsapFallback({
projectId,
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Move timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const delta = change.start - change.element.start;
const domId = change.element.domId;
if (delta === 0 || !domId) continue;
const status = await shiftGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
delta,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
coalesceKey,
recordEdit,
activeCompPath,
changes,
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
mutateChange: (change, changePath) => {
const delta = change.start - change.element.start;
const domId = change.element.domId;
if (delta === 0 || !domId) return null;
return shiftGsapPositions(projectId, changePath, domId, delta);
},
});
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
trySdkBatchPersist,
],
);
@@ -278,41 +303,31 @@ export function useTimelineGroupEditing({
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(maxEnd);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? {
hfId: change.element.hfId,
timingUpdate: { start: change.start, duration: change.duration },
}
: null,
);
const canUseSdk =
!needsExtension &&
sharedPath !== null &&
changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clips", coalesceKey },
);
if (handled) return;
}
const handledBySdk = await trySdkBatchPersist({
changes,
sdkChanges: changes.map((change) =>
change.element.hfId
? {
hfId: change.element.hfId,
timingUpdate: { start: change.start, duration: change.duration },
}
: null,
),
eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
needsExtension,
label: "Resize timeline clips",
coalesceKey,
});
if (handledBySdk) return;
await persistServerBatch(
projectId,
@@ -328,55 +343,43 @@ export function useTimelineGroupEditing({
})),
coalesceKey,
);
await finishTimelineTimingFallback({
await finishGroupTimingGsapFallback({
projectId,
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
label: "Resize timeline clips",
errorLabel: "Failed to scale GSAP positions",
coalesceKey,
recordEdit,
activeCompPath,
changes,
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
mutateChange: (change, changePath) => {
const domId = change.element.domId;
const timingChanged =
change.start !== change.element.start || change.duration !== change.element.duration;
if (!timingChanged || !domId) return null;
return scaleGsapPositions(
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Resize timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const domId = change.element.domId;
const timingChanged =
change.start !== change.element.start ||
change.duration !== change.element.duration;
if (!timingChanged || !domId) continue;
const status = await scaleGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
change.element.start,
change.element.duration,
change.start,
change.duration,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
changePath,
domId,
change.element.start,
change.element.duration,
change.start,
change.duration,
);
},
});
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
trySdkBatchPersist,
],
);