Files
hyperframes/packages/studio/src/hooks/useRazorSplit.ts
T

146 lines
4.9 KiB
TypeScript

import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { getTimelineElementLabel } from "../utils/studioHelpers";
import { trackStudioRazorSplit } from "../telemetry/events";
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
import type { RecordEditInput } from "./timelineEditingHelpers";
interface UseRazorSplitOptions {
projectId: string | null;
// fallow-ignore-next-line code-duplication
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
observeProjectFileVersion?: (path: string, version: string | null) => void;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
forceReloadSdkSession?: () => void;
isRecordingRef?: React.RefObject<boolean>;
}
export function useRazorSplit({
projectId,
activeCompPath,
showToast,
writeProjectFile,
observeProjectFileVersion,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
forceReloadSdkSession,
isRecordingRef,
}: UseRazorSplitOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const synchronize = useCallback(() => {
let failure: unknown;
try {
forceReloadSdkSession?.();
} catch (error) {
failure = error;
}
try {
reloadPreview();
} catch (error) {
failure ??= error;
}
if (failure) throw failure;
}, [forceReloadSdkSession, reloadPreview]);
const runCut = useCallback(
async (elements: readonly TimelineElement[], splitTime: number, mode: "single" | "all") => {
const pid = projectIdRef.current;
if (!pid || elements.length === 0) return;
const intents = buildAtomicCutIntents(elements, splitTime, activeCompPath);
const requestedCount = intents.reduce((count, file) => count + file.targets.length, 0);
const label =
mode === "single"
? "Split timeline clip"
: `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`;
// Server writes arrive through the watcher before React can refresh. Keep
// the existing short self-write window active for this owned transaction.
domEditSaveTimestampRef.current = Date.now();
const result = await runAtomicCutTransaction({
projectId: pid,
intents,
label,
writeProjectFile,
recordEdit,
observeProjectFileVersion,
synchronize,
});
trackStudioRazorSplit({ mode, count: result.splitCount });
if (result.syncFailed) {
showToast(
"Cut was saved, but Studio could not refresh it. Reload the preview to resynchronize.",
"error",
);
}
if (result.skippedSelectors.length > 0) {
showToast(
`Some animations use non-ID selectors (${result.skippedSelectors.join(", ")}) and were not retargeted`,
"info",
);
}
return result;
},
[
activeCompPath,
domEditSaveTimestampRef,
observeProjectFileVersion,
recordEdit,
showToast,
synchronize,
writeProjectFile,
],
);
const handleRazorSplit = useCallback(
async (element: TimelineElement, splitTime: number) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
if (!canSplitElementAt(element, splitTime)) return;
try {
const result = await runCut([element], splitTime, "single");
if (!result) return;
if (result.syncFailed) return;
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
showToast(message, "error");
}
},
[isRecordingRef, runCut, showToast],
);
const handleRazorSplitAll = useCallback(
async (splitTime: number) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const splittable = selectSplittableElements(usePlayerStore.getState().elements, splitTime);
if (splittable.length === 0) return;
try {
const result = await runCut(splittable, splitTime, "all");
if (!result) return;
if (result.syncFailed) return;
showToast(`Split ${result.splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to split clips";
showToast(message, "error");
}
},
[isRecordingRef, runCut, showToast],
);
return { handleRazorSplit, handleRazorSplitAll };
}