mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
- revert diamond selection when a rejected retime leaves the source in place - clear project-local ease focus and expansion on player store reset - share one static-position-hold predicate across the tween cache - invalidate the GSAP cache even when a group timing rewrite throws - stamp each lane keyframe's ease from its own source tween - memoize property lanes and row offsets so memo'd diamond lanes hold - use the editable tween duration for drag position commits - restore the pre-t=0 pad in the all-collapsed content origin - clamp the drag ghost and drop placeholder to the collapsed clip height - aria-expanded on the layer disclosure, aria-pressed plus state-specific labels on the keyframe toggle, 24px chevron targets, focus-visible parity
477 lines
18 KiB
TypeScript
477 lines
18 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
// Move/resize operation families remain parallel until SDK graduation.
|
|
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
|
import type { Composition } from "@hyperframes/sdk";
|
|
import type { TimelineElement } from "../player";
|
|
import {
|
|
cutoverCommittedOrThrow,
|
|
sdkTimingBatchPersist,
|
|
type PublishSdkSession,
|
|
} from "../utils/sdkCutover";
|
|
import {
|
|
buildTimelineMoveTimingPatch,
|
|
buildTimelineResizeTimingPatch,
|
|
extendRootDurationIfNeeded,
|
|
formatTimelineAttributeNumber,
|
|
patchIframeDomTiming,
|
|
playbackStartAttributeForElement,
|
|
persistTimelineBatchEdit,
|
|
type PersistTimelineBatchChange,
|
|
type RecordEditInput,
|
|
} from "./timelineEditingHelpers";
|
|
import {
|
|
captureDurationRollback,
|
|
finishGroupTimingGsapFallback,
|
|
readFileContent,
|
|
scaleGsapPositions,
|
|
shiftGsapPositions,
|
|
syncPreviewContentDuration,
|
|
} from "./timelineTimingSync";
|
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
|
|
export interface TimelineGroupMoveChange {
|
|
element: TimelineElement;
|
|
start: number;
|
|
track?: number;
|
|
}
|
|
|
|
export interface TimelineGroupResizeChange {
|
|
element: TimelineElement;
|
|
start: number;
|
|
duration: number;
|
|
playbackStart?: number;
|
|
}
|
|
|
|
export interface TimelineGroupCommitOptions {
|
|
beforeTiming?: Promise<void>;
|
|
coalesceKey?: string;
|
|
/** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
|
|
coalesceMs?: number;
|
|
}
|
|
|
|
interface UseTimelineGroupEditingOptions {
|
|
activeCompPath: string | null;
|
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
editQueueRef: MutableRefObject<Promise<unknown>>;
|
|
forceReloadSdkSession?: () => void;
|
|
invalidateGsapCache?: () => void;
|
|
isRecordingRef?: RefObject<boolean>;
|
|
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
|
projectIdRef: MutableRefObject<string | null>;
|
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
reloadPreview: () => void;
|
|
sdkSession?: Composition | null;
|
|
publishSdkSession?: PublishSdkSession;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
}
|
|
|
|
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
|
|
return element.sourceFile || activeCompPath || "index.html";
|
|
}
|
|
|
|
function allChangesSharePath(
|
|
changes: readonly { element: TimelineElement }[],
|
|
activeCompPath: string | null,
|
|
): string | null {
|
|
const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null;
|
|
if (!firstPath) return null;
|
|
return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath)
|
|
? firstPath
|
|
: null;
|
|
}
|
|
|
|
function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string {
|
|
return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
|
}
|
|
|
|
function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string {
|
|
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
|
}
|
|
|
|
function toSdkTimingChanges<T extends { element: TimelineElement }>(
|
|
changes: readonly T[],
|
|
timingUpdate: (change: T) => { start: number; duration?: number },
|
|
): Array<{ hfId: string; timingUpdate: { start: number; duration?: number } } | null> {
|
|
return changes.map((change) =>
|
|
change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null,
|
|
);
|
|
}
|
|
|
|
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
|
|
return (
|
|
change.playbackStart != null ||
|
|
(change.start !== change.element.start && change.element.playbackStart != null)
|
|
);
|
|
}
|
|
|
|
export function useTimelineGroupEditing({
|
|
activeCompPath,
|
|
domEditSaveTimestampRef,
|
|
editQueueRef,
|
|
forceReloadSdkSession,
|
|
invalidateGsapCache,
|
|
isRecordingRef,
|
|
pendingTimelineEditPathRef,
|
|
previewIframeRef,
|
|
projectIdRef,
|
|
recordEdit,
|
|
reloadPreview,
|
|
sdkSession,
|
|
publishSdkSession,
|
|
showToast,
|
|
writeProjectFile,
|
|
}: UseTimelineGroupEditingOptions) {
|
|
const enqueueGroupOperation = useCallback(
|
|
(label: string, operation: (projectId: string) => Promise<void>): Promise<void> => {
|
|
if (isRecordingRef?.current) {
|
|
showToast("Cannot edit timeline while recording", "error");
|
|
return Promise.reject(new Error(`${label}: blocked while recording`));
|
|
}
|
|
const projectId = projectIdRef.current;
|
|
if (!projectId) return Promise.reject(new Error(`${label}: no active project`));
|
|
const run = editQueueRef.current.then(() => operation(projectId));
|
|
// Keep the shared edit queue from wedging on a rejection, but return the raw
|
|
// (rejecting) promise so the gesture owner can roll back on a real failure.
|
|
editQueueRef.current = run.then(
|
|
() => undefined,
|
|
(error) => {
|
|
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
|
},
|
|
);
|
|
return run;
|
|
},
|
|
[editQueueRef, isRecordingRef, projectIdRef, showToast],
|
|
);
|
|
|
|
const persistServerBatch = useCallback(
|
|
async (
|
|
projectId: string,
|
|
label: string,
|
|
batchChanges: PersistTimelineBatchChange[],
|
|
coalesceKey: string,
|
|
coalesceMs?: number,
|
|
) => {
|
|
await persistTimelineBatchEdit({
|
|
projectId,
|
|
activeCompPath,
|
|
label,
|
|
changes: batchChanges,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
coalesceKey,
|
|
coalesceMs,
|
|
});
|
|
forceReloadSdkSession?.();
|
|
},
|
|
[
|
|
activeCompPath,
|
|
domEditSaveTimestampRef,
|
|
forceReloadSdkSession,
|
|
pendingTimelineEditPathRef,
|
|
recordEdit,
|
|
writeProjectFile,
|
|
],
|
|
);
|
|
|
|
// 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;
|
|
coalesceMs?: number;
|
|
}): 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;
|
|
const result = await 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),
|
|
publishSession: publishSdkSession,
|
|
},
|
|
{
|
|
label: input.label,
|
|
coalesceKey: input.coalesceKey,
|
|
coalesceMs: input.coalesceMs,
|
|
skipRefresh: true,
|
|
},
|
|
);
|
|
return cutoverCommittedOrThrow(result);
|
|
},
|
|
[
|
|
activeCompPath,
|
|
domEditSaveTimestampRef,
|
|
projectIdRef,
|
|
publishSdkSession,
|
|
recordEdit,
|
|
reloadPreview,
|
|
sdkSession,
|
|
writeProjectFile,
|
|
],
|
|
);
|
|
|
|
const handleTimelineGroupMove = useCallback(
|
|
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
|
|
if (changes.length === 0) return Promise.resolve();
|
|
for (const change of changes) {
|
|
const attrs: Array<[string, string]> = [
|
|
["data-start", formatTimelineAttributeNumber(change.start)],
|
|
];
|
|
if (change.track != null) {
|
|
attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
|
|
}
|
|
patchIframeDomTiming(previewIframeRef.current, change.element, attrs, activeCompPath);
|
|
}
|
|
|
|
// TRACK-ONLY batch: every change keeps its start (moves never carry a
|
|
// duration change), so nothing timing-related changed — the batch only
|
|
// rewrites data-track-index, which the renderer never reads (documented
|
|
// in core runtime/timeline.ts; track is a studio lane concept). The live
|
|
// DOM patch above + the gesture owner's optimistic store update cover the
|
|
// in-flight UI; after the complete lane + z transaction, that owner
|
|
// refreshes the preview so its runtime manifest converges to disk. There
|
|
// is still nothing to GSAP-shift here, so skip this fallback entirely.
|
|
// Running it anyway is what made the mirrored z-order lane move blink —
|
|
// a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback
|
|
// used to full-reload the iframe when there was no script to soft-swap
|
|
// (it now rebinds the runtime timing in place, but a track-only batch
|
|
// needs NO preview sync at all, so the skip stays).
|
|
const trackOnly = changes.every((change) => change.start === change.element.start);
|
|
|
|
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
|
|
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
// persist can roll the readout + live root back (see captureDurationRollback).
|
|
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
// needsExtension gates the SDK path (setTiming can't grow the root duration),
|
|
// so read the store BEFORE the readout sync below optimistically updates it.
|
|
// Track-only batches leave every clip end unchanged, so both this and the
|
|
// readout sync below are provable no-ops there — kept unconditional so the
|
|
// duration machinery stays on one code path.
|
|
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);
|
|
const coalesceMs = options?.coalesceMs;
|
|
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
|
|
await options?.beforeTiming;
|
|
const handledBySdk = await trySdkBatchPersist({
|
|
changes,
|
|
sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
|
|
eligible: changes.every((change) => change.track == null),
|
|
needsExtension,
|
|
label: "Move timeline clips",
|
|
coalesceKey,
|
|
coalesceMs,
|
|
});
|
|
if (!handledBySdk) {
|
|
await persistServerBatch(
|
|
projectId,
|
|
"Move timeline clips",
|
|
changes.map((change) => ({
|
|
element: change.element,
|
|
buildPatches: (original, target) =>
|
|
buildTimelineMoveTimingPatch(
|
|
original,
|
|
target,
|
|
change.start,
|
|
change.element.duration,
|
|
change.track,
|
|
),
|
|
})),
|
|
coalesceKey,
|
|
coalesceMs,
|
|
);
|
|
}
|
|
// Track-only: no timing delta → no GSAP positions to shift and no
|
|
// reload (see the trackOnly doc above). Mixed batches (any start
|
|
// change) keep the full fallback below.
|
|
if (trackOnly) return;
|
|
// The timing persist above already committed to disk, so the cached
|
|
// GSAP read is stale whether or not the position rewrite succeeded —
|
|
// invalidate on the error path too (matches the single-element path's
|
|
// `.finally`), or a failed rewrite leaves the editor reading old tweens.
|
|
try {
|
|
await finishGroupTimingGsapFallback({
|
|
projectId,
|
|
iframe: previewIframeRef.current,
|
|
reloadPreview,
|
|
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);
|
|
},
|
|
});
|
|
} finally {
|
|
invalidateGsapCache?.();
|
|
}
|
|
}).catch((error) => {
|
|
// Failed persist: revert the optimistic duration readout + live root
|
|
// alongside the gesture owner's store rollback.
|
|
rollbackDuration();
|
|
showToast(getStudioSaveErrorMessage(error), "error");
|
|
throw error;
|
|
});
|
|
},
|
|
[
|
|
activeCompPath,
|
|
enqueueGroupOperation,
|
|
persistServerBatch,
|
|
previewIframeRef,
|
|
recordEdit,
|
|
reloadPreview,
|
|
trySdkBatchPersist,
|
|
showToast,
|
|
invalidateGsapCache,
|
|
],
|
|
);
|
|
|
|
const handleTimelineGroupResize = useCallback(
|
|
(changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => {
|
|
if (changes.length === 0) return Promise.resolve();
|
|
for (const change of changes) {
|
|
const liveAttrs: Array<[string, string]> = [
|
|
["data-start", formatTimelineAttributeNumber(change.start)],
|
|
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
|
];
|
|
if (change.playbackStart != null) {
|
|
const liveAttr = playbackStartAttributeForElement(change.element);
|
|
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
|
|
}
|
|
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
|
|
}
|
|
|
|
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
|
|
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
// persist can roll the readout + live root back (see captureDurationRollback).
|
|
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
// 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);
|
|
const coalesceMs = options?.coalesceMs;
|
|
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
|
|
await options?.beforeTiming;
|
|
const handledBySdk = await trySdkBatchPersist({
|
|
changes,
|
|
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
start: change.start,
|
|
duration: change.duration,
|
|
})),
|
|
eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
|
|
needsExtension,
|
|
label: "Resize timeline clips",
|
|
coalesceKey,
|
|
coalesceMs,
|
|
});
|
|
if (!handledBySdk) {
|
|
await persistServerBatch(
|
|
projectId,
|
|
"Resize timeline clips",
|
|
changes.map((change) => ({
|
|
element: change.element,
|
|
buildPatches: (original, target) =>
|
|
buildTimelineResizeTimingPatch(original, target, change.element, {
|
|
start: change.start,
|
|
duration: change.duration,
|
|
playbackStart: change.playbackStart,
|
|
}),
|
|
})),
|
|
coalesceKey,
|
|
coalesceMs,
|
|
);
|
|
}
|
|
// See the move path: the timing persist is already on disk, so the GSAP
|
|
// cache must be invalidated even when the position rewrite throws.
|
|
try {
|
|
await finishGroupTimingGsapFallback({
|
|
projectId,
|
|
iframe: previewIframeRef.current,
|
|
reloadPreview,
|
|
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,
|
|
changePath,
|
|
domId,
|
|
change.element.start,
|
|
change.element.duration,
|
|
change.start,
|
|
change.duration,
|
|
);
|
|
},
|
|
});
|
|
} finally {
|
|
invalidateGsapCache?.();
|
|
}
|
|
}).catch((error) => {
|
|
// Failed persist: revert the optimistic duration readout + live root
|
|
// alongside the gesture owner's store rollback.
|
|
rollbackDuration();
|
|
showToast(getStudioSaveErrorMessage(error), "error");
|
|
throw error;
|
|
});
|
|
},
|
|
[
|
|
activeCompPath,
|
|
enqueueGroupOperation,
|
|
persistServerBatch,
|
|
previewIframeRef,
|
|
recordEdit,
|
|
reloadPreview,
|
|
trySdkBatchPersist,
|
|
showToast,
|
|
invalidateGsapCache,
|
|
],
|
|
);
|
|
|
|
return { handleTimelineGroupMove, handleTimelineGroupResize };
|
|
}
|