mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user