mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-06 17:46:03 +00:00
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
176 lines
6.2 KiB
TypeScript
176 lines
6.2 KiB
TypeScript
// 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 };
|
|
}
|