mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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:
@@ -52,6 +52,8 @@ export interface StudioPreviewAreaProps {
|
||||
) => Promise<void> | void;
|
||||
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
||||
setCompIdToSrc: (map: Map<string, string>) => void;
|
||||
setCompositionLoading: (loading: boolean) => void;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
@@ -73,6 +75,8 @@ export function StudioPreviewArea({
|
||||
handleTimelineElementResize,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
setCompIdToSrc,
|
||||
setCompositionLoading,
|
||||
shouldShowSelectedDomBounds,
|
||||
@@ -146,6 +150,8 @@ export function StudioPreviewArea({
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
||||
onSplitElement={handleTimelineElementSplit}
|
||||
onRazorSplit={handleRazorSplit}
|
||||
onRazorSplitAll={handleRazorSplitAll}
|
||||
onSelectTimelineElement={handleTimelineElementSelect}
|
||||
onDeleteAllKeyframes={(_elId) => {
|
||||
const anim =
|
||||
|
||||
@@ -4,13 +4,18 @@ import {
|
||||
getNextTimelineZoomPercent,
|
||||
getTimelineZoomPercent,
|
||||
} from "../player/components/timelineZoom";
|
||||
import { useTimelineZoom } from "../player/components/useTimelineZoom";
|
||||
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
||||
import {
|
||||
STUDIO_KEYFRAMES_ENABLED,
|
||||
STUDIO_RAZOR_TOOL_ENABLED,
|
||||
} from "./editor/manualEditingAvailability";
|
||||
import { Tooltip } from "./ui";
|
||||
import { Scissors } from "../icons/SystemIcons";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
|
||||
function AutoKeyframeToggle() {
|
||||
const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
||||
@@ -58,14 +63,17 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
const anims = session.selectedGsapAnimations;
|
||||
const kfAnim = anims.find((a) => a.keyframes);
|
||||
|
||||
const computePct = (time: number) => {
|
||||
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
|
||||
return elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
};
|
||||
|
||||
let state: "active" | "inactive" | "none" = "none";
|
||||
if (kfAnim?.keyframes && sel) {
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
const pct =
|
||||
elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
const pct = computePct(currentTime);
|
||||
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
||||
? "active"
|
||||
: "inactive";
|
||||
@@ -74,15 +82,15 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
return { state, onToggle: sel ? onToggle : undefined };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function TimelineToolbar({
|
||||
toggleTimelineVisibility,
|
||||
domEditSession,
|
||||
onSplitElement,
|
||||
}: TimelineToolbarProps) {
|
||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
||||
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
||||
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
||||
const activeTool = usePlayerStore((s) => s.activeTool);
|
||||
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
||||
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
||||
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
|
||||
|
||||
@@ -93,6 +101,38 @@ export function TimelineToolbar({
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||
Timeline
|
||||
</div>
|
||||
{STUDIO_RAZOR_TOOL_ENABLED && (
|
||||
<div className="flex items-center border border-neutral-800 rounded overflow-hidden">
|
||||
<Tooltip label="Selection tool (V)">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTool("select")}
|
||||
className={`flex h-6 w-6 items-center justify-center transition-colors ${
|
||||
activeTool === "select"
|
||||
? "bg-neutral-700 text-neutral-200"
|
||||
: "text-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Razor tool (B)">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTool("razor")}
|
||||
className={`flex h-6 w-6 items-center justify-center transition-colors ${
|
||||
activeTool === "razor"
|
||||
? "bg-neutral-700 text-neutral-200"
|
||||
: "text-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<Scissors size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
||||
<>
|
||||
<Tooltip
|
||||
@@ -138,9 +178,7 @@ export function TimelineToolbar({
|
||||
const el = selectedElementId
|
||||
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
||||
: null;
|
||||
const splittable =
|
||||
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
|
||||
if (!splittable) return null;
|
||||
if (!el || !canSplitElement(el)) return null;
|
||||
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
|
||||
return (
|
||||
<Tooltip label="Split clip at playhead (S)">
|
||||
|
||||
@@ -77,6 +77,12 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
true,
|
||||
);
|
||||
|
||||
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
|
||||
false,
|
||||
);
|
||||
|
||||
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
||||
|
||||
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
||||
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
||||
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getTimelineToggleTitle,
|
||||
} from "../../utils/timelineDiscovery";
|
||||
|
||||
interface NLELayoutProps {
|
||||
interface NLELayoutProps extends TimelineEditCallbacks {
|
||||
projectId: string;
|
||||
portrait?: boolean;
|
||||
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
||||
@@ -59,23 +59,7 @@ interface NLELayoutProps {
|
||||
blockName: string,
|
||||
position: { left: number; top: number },
|
||||
) => Promise<void> | void;
|
||||
/** Persist timeline move actions back into source HTML */
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
onResizeElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
||||
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
||||
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
||||
/** Whether the timeline panel is visible (default: true) */
|
||||
@@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
onSplitElement,
|
||||
onRazorSplit,
|
||||
onRazorSplitAll,
|
||||
onSelectTimelineElement,
|
||||
onDeleteKeyframe,
|
||||
onDeleteAllKeyframes,
|
||||
@@ -460,6 +446,8 @@ export const NLELayout = memo(function NLELayout({
|
||||
onResizeElement={onResizeElement}
|
||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||
onSplitElement={onSplitElement}
|
||||
onRazorSplit={onRazorSplit}
|
||||
onRazorSplitAll={onRazorSplitAll}
|
||||
onSelectElement={onSelectTimelineElement}
|
||||
onDeleteKeyframe={onDeleteKeyframe}
|
||||
onDeleteAllKeyframes={onDeleteAllKeyframes}
|
||||
|
||||
Reference in New Issue
Block a user