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
+74 -76
View File
@@ -7,12 +7,16 @@ import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
buildTimelineFileDropPlacements,
fitTimelineAssetGeometry,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetInitialGeometry,
resolveTimelineAssetCompositionSize,
resolveTimelineAssetSrc,
} from "../utils/timelineAssetDrop";
import { generateId } from "../utils/generateId";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
import {
getTimelineElementLabel,
collectHtmlIds,
@@ -23,15 +27,16 @@ import {
buildPatchTarget,
patchIframeDomTiming,
persistTimelineEdit,
readFileContent,
foldedShiftGsapMutation,
foldedScaleGsapMutation,
formatTimelineAttributeNumber,
finishTimelineTimingFallback,
extendRootDurationIfNeeded,
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
} from "./timelineEditingHelpers";
import {
finishClipTimingFallback,
readFileContent,
syncPreviewContentDuration,
} from "./timelineTimingSync";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import {
@@ -155,36 +160,35 @@ export function useTimelineEditing({
if (!startChanged) return reorderDone;
// 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(updates.start + element.duration);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
};
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
const moveFallback = () =>
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
const pid = projectIdRef.current;
const delta = updates.start - element.start;
const domId = element.domId;
return finishTimelineTimingFallback({
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
// Soft-reload with the server's rewritten GSAP script instead of a full
// iframe reload — a timing-only move already patched the DOM + store, so
// swapping the script in place avoids the all-clips flash. Falls back to
// reloadPreview() when the soft path can't apply. (See timelineTimingSync.)
finishClipTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + element.duration,
reloadPreview,
gsapMutation:
delta !== 0 && domId && pid
? foldedShiftGsapMutation({
projectId: pid,
targetPath,
domId,
delta,
label: "Move timeline clip",
coalesceKey,
recordEdit,
})
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
});
});
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
projectId: projectIdRef.current,
targetPath,
domId: element.domId,
label: "Move timeline clip",
coalesceKey,
recordEdit,
edit: { kind: "shift", delta: updates.start - element.start },
}),
);
return reorderDone.then(() => {
if (sdkSession && element.hfId && !needsExtension) {
return sdkTimingPersist(
@@ -245,6 +249,12 @@ export function useTimelineEditing({
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
// 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(updates.start + updates.duration);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const targetPath = element.sourceFile || activeCompPath || "index.html";
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineResizeTimingPatch(original, target, element, updates);
@@ -253,37 +263,28 @@ export function useTimelineEditing({
updates.playbackStart != null ||
(updates.start !== element.start && element.playbackStart != null);
// Server-path fallback: after persisting the attr patch, scale GSAP tween
// positions/durations on the server. Extending edits can keep the iframe
// live unless a GSAP source rewrite needs a fresh run.
// positions/durations on the server, then soft-reload with the rewritten
// script (timing-only resize) — same no-flash path as move; full reload is
// the fallback.
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
const timingChanged =
updates.start !== element.start || updates.duration !== element.duration;
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
const resizeFallback = () =>
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
const pid = projectIdRef.current;
const domId = element.domId;
return finishTimelineTimingFallback({
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
finishClipTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + updates.duration,
reloadPreview,
gsapMutation:
timingChanged && domId && pid
? foldedScaleGsapMutation({
projectId: pid,
targetPath,
domId,
from: { start: element.start, duration: element.duration },
to: { start: updates.start, duration: updates.duration },
label: "Resize timeline clip",
coalesceKey,
recordEdit,
})
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
});
});
projectId: projectIdRef.current,
targetPath,
domId: element.domId,
label: "Resize timeline clip",
coalesceKey,
recordEdit,
edit: {
kind: "scale",
from: { start: element.start, duration: element.duration },
to: { start: updates.start, duration: updates.duration },
},
}),
);
if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
return sdkTimingPersist(
element.hfId,
@@ -384,8 +385,19 @@ export function useTimelineEditing({
changed?: boolean;
content?: string;
};
const patchedContent =
const removedContent =
typeof removeData.content === "string" ? removeData.content : originalContent;
// Content-driven duration: shrink the composition to the furthest
// remaining clip end, read from the post-removal SOURCE (raw
// data-duration), so deleting the last/longest clip removes trailing
// empty space. Measured from the source, not the store, whose
// durations are runtime-truncated.
const deleteContentEnd = furthestClipEndFromSource(removedContent);
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
// Optimistically reflect the shrunk length in the readout/seek bar.
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
usePlayerStore.getState().setDuration(deleteContentEnd);
}
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
@@ -469,13 +481,17 @@ export function useTimelineEditing({
originalContent,
buildTimelineAssetInsertHtml({
id: newId,
hfId: `hf-${generateId()}`,
assetPath: resolvedAssetSrc,
kind,
start: normalizedStart,
duration: normalizedDuration,
track: placement.track,
zIndex: newElementZIndex,
geometry: resolveTimelineAssetInitialGeometry(originalContent),
geometry: fitTimelineAssetGeometry(
null,
resolveTimelineAssetCompositionSize(originalContent),
),
}),
);
@@ -532,17 +548,6 @@ export function useTimelineEditing({
const placements = buildTimelineFileDropPlacements(
placement ?? { start: 0, track: 0 },
durations,
timelineElements
.filter(
(te) =>
(te.sourceFile || activeCompPath || "index.html") ===
(activeCompPath || "index.html"),
)
.map((te) => ({
start: te.start,
duration: te.duration,
track: te.track,
})),
);
for (const [index, assetPath] of uploaded.entries()) {
await handleTimelineAssetDrop(
@@ -552,14 +557,7 @@ export function useTimelineEditing({
);
}
},
[
activeCompPath,
handleTimelineAssetDrop,
timelineElements,
uploadProjectFiles,
isRecordingRef,
showToast,
],
[handleTimelineAssetDrop, uploadProjectFiles, isRecordingRef, showToast],
);
const handleBlockedTimelineEdit = useCallback(