mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-06 17:46:03 +00:00
fix(studio): address PR #2347 review findings (rounds 1-2)
Review 1 (restore commit): - asset reveal now clears any open preview overlay (stuck-overlay repro: preview on A, click already-added B — A stayed open over the reveal) - duration readout rolls back on failed persist: captureDurationRollback snapshots store + live root data-duration before the optimistic sync and restores both in every move/resize/delete/group catch (golden's previousDuration pattern) - asset preview opened during running playback dismisses immediately (the RAF loop bypasses the store, so the subscription alone never fired) - persistTimelineBatchEdit resolves the target (findTagByTarget) before treating identical output as a no-op — a mistargeted member now throws like the single-element path instead of being silently dropped - a post-mutation history-fold failure no longer suppresses the preview sync: fold errors are surfaced separately and the rewritten script still syncs (previously the preview kept stale GSAP positions with no recovery) - timelineRevealScroll guards degenerate viewports (windowSize <= 0) - CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches Review 2 (single-source-of-truth pass): - createTimelineElementFromManifestClip — the one manifest->element boundary — now carries authoredTrack and stackingContextId; expanded sub-comp children preserve both (authoredTrack in their OWN file's space) - authoredTrackForLane scopes occupants to the dragged clip's sourceFile (a foreign file's authored values are a different coordinate space); nearest-same-file-lane offset fallback - optimistic store updates mirror the persisted track into authoredTrack (and roll it back on failure), so consecutive drags before a reload resolve from fresh data - spill sub-lanes: documented decision — dropping onto a spill lane is a legitimate same-track join (occupants share the authored track by construction); false 'never a lane-move target' docstring rewritten - single-element fallback persists vertical-only moves (early return now requires neither start nor track changed; live DOM patch includes data-track-index) - canonical contextKey helper for stacking-context normalization - new pipeline test crosses the REAL factory boundary (sparse authored tracks -> factory -> expansion -> normalize -> drag commit -> persisted attribute), no injected fields
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// Asset-drop handlers for the timeline: drop an existing project asset at a
|
||||
// placement, or upload dragged-in OS files and place them sequentially.
|
||||
// Extracted verbatim from useTimelineEditing.ts to keep it under the studio
|
||||
// 600-line cap.
|
||||
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
buildTimelineAssetId,
|
||||
buildTimelineAssetInsertHtml,
|
||||
buildTimelineFileDropPlacements,
|
||||
fitTimelineAssetGeometry,
|
||||
getTimelineAssetKind,
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetCompositionSize,
|
||||
resolveTimelineAssetSrc,
|
||||
} from "../utils/timelineAssetDrop";
|
||||
import { generateId } from "../utils/generateId";
|
||||
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
|
||||
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
||||
import { readFileContent } from "./timelineTimingSync";
|
||||
|
||||
interface UseTimelineAssetDropOpsOptions {
|
||||
projectIdRef: MutableRefObject<string | null>;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
isRecordingRef?: RefObject<boolean>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
export function useTimelineAssetDropOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
}: UseTimelineAssetDropOpsOptions) {
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineAssetDrop = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
durationOverride?: number,
|
||||
) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
if (!kind) {
|
||||
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
||||
const duration =
|
||||
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
||||
? durationOverride
|
||||
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
||||
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
||||
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
||||
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const relevantElements = timelineElements.filter(
|
||||
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||
);
|
||||
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
||||
|
||||
const patchedContent = insertTimelineAssetIntoSource(
|
||||
originalContent,
|
||||
buildTimelineAssetInsertHtml({
|
||||
id: newId,
|
||||
hfId: `hf-${generateId()}`,
|
||||
assetPath: resolvedAssetSrc,
|
||||
kind,
|
||||
start: normalizedStart,
|
||||
duration: normalizedDuration,
|
||||
track: placement.track,
|
||||
zIndex: newElementZIndex,
|
||||
geometry: fitTimelineAssetGeometry(
|
||||
null,
|
||||
resolveTimelineAssetCompositionSize(originalContent),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Add timeline asset",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineFileDrop = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const uploaded = await uploadProjectFiles(files);
|
||||
if (uploaded.length === 0) return;
|
||||
const durations: number[] = [];
|
||||
for (const assetPath of uploaded) {
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
||||
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
||||
}
|
||||
const placements = buildTimelineFileDropPlacements(
|
||||
placement ?? { start: 0, track: 0 },
|
||||
durations,
|
||||
);
|
||||
for (const [index, assetPath] of uploaded.entries()) {
|
||||
await handleTimelineAssetDrop(
|
||||
assetPath,
|
||||
placements[index] ?? placements[0],
|
||||
durations[index],
|
||||
);
|
||||
}
|
||||
},
|
||||
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
|
||||
);
|
||||
|
||||
return { handleTimelineAssetDrop, handleTimelineFileDrop };
|
||||
}
|
||||
Reference in New Issue
Block a user