mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): razor/blade tool UI for timeline clip splitting (#1331)
Wire the razor tool into Studio's timeline UI: - B enters razor mode (crosshair cursor + red vertical guide line) - Click any clip to split at the click position - Shift+click splits all clips across every track at that time - V or Escape exits razor mode - Toolbar shows selection arrow / scissors toggle Add useRazorSplit hook for split orchestration (HTML + GSAP mutation). Add activeTool state to playerStore. Add preview reload after timeline move/resize operations so the composition re-renders with updated timing.
This commit is contained in:
@@ -6,6 +6,8 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
||||
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
||||
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
|
||||
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
|
||||
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
|
||||
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
||||
@@ -327,7 +329,7 @@ export function useAppHotkeys({
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (
|
||||
element &&
|
||||
["video", "audio", "img"].includes(element.tag) &&
|
||||
canSplitElement(element) &&
|
||||
currentTime > element.start &&
|
||||
currentTime < element.start + element.duration
|
||||
) {
|
||||
@@ -338,6 +340,51 @@ export function useAppHotkeys({
|
||||
}
|
||||
}
|
||||
|
||||
// B — toggle razor tool
|
||||
if (
|
||||
STUDIO_RAZOR_TOOL_ENABLED &&
|
||||
event.key.toLowerCase() === "b" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
||||
setActiveTool(activeTool === "razor" ? "select" : "razor");
|
||||
return;
|
||||
}
|
||||
|
||||
// V — return to selection tool
|
||||
if (
|
||||
event.key.toLowerCase() === "v" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
usePlayerStore.getState().setActiveTool("select");
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — exit razor mode (only when no selection to deselect first)
|
||||
if (event.key === "Escape" && !isEditableTarget(event.target)) {
|
||||
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
|
||||
usePlayerStore.getState();
|
||||
if (activeTool === "razor") {
|
||||
if (selectedElementId) {
|
||||
setSelectedElementId(null);
|
||||
} else {
|
||||
setActiveTool("select");
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
|
||||
if (
|
||||
(event.key === "Delete" || event.key === "Backspace") &&
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
|
||||
import {
|
||||
canSplitElement,
|
||||
buildPatchTarget,
|
||||
readFileContent,
|
||||
SPLIT_BOUNDARY_EPSILON_S,
|
||||
} from "../utils/timelineElementSplit";
|
||||
import type { RecordEditInput } from "./useTimelineEditing";
|
||||
|
||||
interface UseRazorSplitOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
isRecordingRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
function generateSplitId(existingIds: string[], baseId: string): string {
|
||||
let newId = `${baseId}-split`;
|
||||
let suffix = 2;
|
||||
while (existingIds.includes(newId)) {
|
||||
newId = `${baseId}-split-${suffix++}`;
|
||||
}
|
||||
return newId;
|
||||
}
|
||||
|
||||
async function splitHtmlElement(
|
||||
projectId: string,
|
||||
targetPath: string,
|
||||
patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
|
||||
splitTime: number,
|
||||
newId: string,
|
||||
): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Split request failed");
|
||||
return (await response.json()) as { ok: boolean; changed?: boolean; content?: string };
|
||||
}
|
||||
|
||||
async function splitGsapAnimations(
|
||||
projectId: string,
|
||||
targetPath: string,
|
||||
originalId: string,
|
||||
newId: string,
|
||||
splitTime: number,
|
||||
elementStart: number,
|
||||
elementDuration: number,
|
||||
): Promise<{ content: string | null; skippedSelectors?: string[] }> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "split-animations",
|
||||
originalId,
|
||||
newId,
|
||||
splitTime,
|
||||
elementStart,
|
||||
elementDuration,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const errorBody = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
if (errorBody?.error === "no GSAP script found in file") {
|
||||
return { content: null };
|
||||
}
|
||||
throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
after?: string;
|
||||
skippedSelectors?: string[];
|
||||
};
|
||||
return {
|
||||
content: data.ok && data.after ? data.after : null,
|
||||
skippedSelectors: data.skippedSelectors,
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function executeSplit(
|
||||
pid: string,
|
||||
element: TimelineElement,
|
||||
splitTime: number,
|
||||
activeCompPath: string | null,
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>,
|
||||
): Promise<{
|
||||
targetPath: string;
|
||||
originalContent: string;
|
||||
patchedContent: string;
|
||||
changed: boolean;
|
||||
skippedSelectors?: string[];
|
||||
}> {
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) throw new Error("Clip is missing a patchable target.");
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
|
||||
|
||||
const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId);
|
||||
if (!splitResult.ok) throw new Error("Failed to split clip.");
|
||||
if (!splitResult.changed) {
|
||||
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
||||
}
|
||||
|
||||
let patchedContent =
|
||||
typeof splitResult.content === "string" ? splitResult.content : originalContent;
|
||||
|
||||
let skippedSelectors: string[] | undefined;
|
||||
if (element.domId) {
|
||||
try {
|
||||
const gsapResult = await splitGsapAnimations(
|
||||
pid,
|
||||
targetPath,
|
||||
element.domId,
|
||||
newId,
|
||||
splitTime,
|
||||
element.start,
|
||||
element.duration,
|
||||
);
|
||||
if (gsapResult.content) patchedContent = gsapResult.content;
|
||||
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
|
||||
} catch (gsapError) {
|
||||
// GSAP mutation failed — the HTML split already wrote to disk.
|
||||
// Restore the original content to avoid a corrupt half-split state.
|
||||
await writeProjectFile(targetPath, originalContent);
|
||||
throw gsapError;
|
||||
}
|
||||
}
|
||||
|
||||
return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors };
|
||||
}
|
||||
|
||||
export function useRazorSplit({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
}: UseRazorSplitOptions) {
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const handleRazorSplit = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (element: TimelineElement, splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid || !canSplitElement(element)) return;
|
||||
|
||||
const clipStart = element.start;
|
||||
const clipEnd = element.start + element.duration;
|
||||
if (
|
||||
splitTime <= clipStart + SPLIT_BOUNDARY_EPSILON_S ||
|
||||
splitTime >= clipEnd - SPLIT_BOUNDARY_EPSILON_S
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
|
||||
await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
|
||||
|
||||
if (!changed) {
|
||||
showToast("Failed to split clip — playhead may be outside the clip", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Split timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
||||
if (skippedSelectors?.length) {
|
||||
showToast(
|
||||
`Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleRazorSplitAll = useCallback(
|
||||
async (splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const { elements } = usePlayerStore.getState();
|
||||
const splittable = elements.filter(
|
||||
(el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
|
||||
);
|
||||
if (splittable.length === 0) return;
|
||||
|
||||
try {
|
||||
const originals = new Map<string, string>();
|
||||
for (const el of splittable) {
|
||||
const path = el.sourceFile || activeCompPath || "index.html";
|
||||
if (!originals.has(path)) {
|
||||
originals.set(path, await readFileContent(pid, path));
|
||||
}
|
||||
}
|
||||
|
||||
let splitCount = 0;
|
||||
const finalContent = new Map<string, string>();
|
||||
|
||||
for (const element of splittable) {
|
||||
const result = await executeSplit(
|
||||
pid,
|
||||
element,
|
||||
splitTime,
|
||||
activeCompPath,
|
||||
writeProjectFile,
|
||||
);
|
||||
if (result.changed) {
|
||||
finalContent.set(result.targetPath, result.patchedContent);
|
||||
await writeProjectFile(result.targetPath, result.patchedContent);
|
||||
splitCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (splitCount === 0) return;
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await recordEdit({
|
||||
label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
|
||||
kind: "timeline",
|
||||
files: Object.fromEntries(
|
||||
[...finalContent].map(([path, after]) => [
|
||||
path,
|
||||
{ before: originals.get(path) ?? "", after },
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to split clips";
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleRazorSplit, handleRazorSplitAll };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
import {
|
||||
buildTimelineAssetId,
|
||||
buildTimelineAssetInsertHtml,
|
||||
@@ -30,7 +31,7 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
export interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
@@ -386,108 +387,24 @@ export function useTimelineEditing({
|
||||
[showToast],
|
||||
);
|
||||
|
||||
const handleTimelineElementSplit = useCallback(
|
||||
async (element: TimelineElement, splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
|
||||
const splittableTags = new Set(["video", "audio", "img"]);
|
||||
if (
|
||||
element.timelineLocked ||
|
||||
element.timingSource === "implicit" ||
|
||||
element.compositionSrc ||
|
||||
!splittableTags.has(element.tag) ||
|
||||
!element.duration ||
|
||||
!Number.isFinite(element.duration)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (splitTime <= element.start || splitTime >= element.start + element.duration) {
|
||||
showToast("Playhead must be inside the clip to split.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
showToast("Clip is missing a patchable target.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
const existingIds = collectHtmlIds(originalContent);
|
||||
const baseId = element.domId || "clip";
|
||||
let newId = `${baseId}-split`;
|
||||
let suffix = 2;
|
||||
while (existingIds.includes(newId)) {
|
||||
newId = `${baseId}-split-${suffix++}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Split request failed");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
if (!data.ok || !data.changed) {
|
||||
showToast("Failed to split clip — playhead may be outside the clip.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const patchedContent = typeof data.content === "string" ? data.content : originalContent;
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Split timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
const label = getTimelineElementLabel(element);
|
||||
showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
return {
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementSplit,
|
||||
handleTimelineElementSplit: handleRazorSplit,
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
handleTimelineAssetDrop,
|
||||
handleTimelineFileDrop,
|
||||
handleBlockedTimelineEdit,
|
||||
|
||||
Reference in New Issue
Block a user