diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 0b3eec08b..622e229ad 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -525,6 +525,7 @@ export function StudioApp() { handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} handleTimelineGroupMove={timelineEditing.handleTimelineGroupMove} + handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize} handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden} handleToggleElementHidden={timelineEditing.handleToggleElementHidden} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 71ffd0938..c033c828e 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -26,7 +26,10 @@ import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync"; -import type { TimelineGroupMoveChange } from "../hooks/useTimelineGroupEditing"; +import type { + TimelineGroupMoveChange, + TimelineGroupResizeChange, +} from "../hooks/useTimelineGroupEditing"; import { formatTimelineAttributeNumber, patchIframeDomTiming, @@ -65,6 +68,7 @@ export interface StudioPreviewAreaProps { updates: Pick, ) => Promise | void; handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise | void; + handleTimelineGroupResize: (changes: TimelineGroupResizeChange[]) => Promise | void; handleToggleTrackHidden: (track: number, hidden: boolean) => Promise | void; handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; @@ -93,6 +97,7 @@ export function StudioPreviewArea({ handleTimelineElementMove, handleTimelineElementResize, handleTimelineGroupMove, + handleTimelineGroupResize, handleToggleTrackHidden, handleToggleElementHidden, handleBlockedTimelineEdit, @@ -212,13 +217,36 @@ export function StudioPreviewArea({ [previewIframeRef], ); + const handleTimelineGroupResizePreview = useCallback( + (changes: TimelineGroupResizeChange[]) => { + for (const change of changes) { + const attrs: Array<[string, string]> = [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ["data-duration", formatTimelineAttributeNumber(change.duration)], + ]; + if (change.playbackStart != null) { + attrs.push([ + change.element.playbackStartAttr === "playback-start" + ? "data-playback-start" + : "data-media-start", + formatTimelineAttributeNumber(change.playbackStart), + ]); + } + patchIframeDomTiming(previewIframeRef.current, change.element, attrs); + } + }, + [previewIframeRef], + ); + // fallow-ignore-next-line complexity const timelineEditCallbacks = useMemo( () => ({ onMoveElement: handleTimelineElementMove, onResizeElement: handleTimelineElementResize, onMoveElements: handleTimelineGroupMove, + onResizeElements: handleTimelineGroupResize, onPreviewMoveElements: handleTimelineGroupMovePreview, + onPreviewResizeElements: handleTimelineGroupResizePreview, onToggleTrackHidden: handleToggleTrackHidden, onToggleElementHidden: handleToggleElementHidden, onBlockedEditAttempt: handleBlockedTimelineEdit, @@ -322,6 +350,8 @@ export function StudioPreviewArea({ handleTimelineElementResize, handleTimelineGroupMove, handleTimelineGroupMovePreview, + handleTimelineGroupResize, + handleTimelineGroupResizePreview, handleToggleTrackHidden, handleToggleElementHidden, handleBlockedTimelineEdit, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index b5a1372ed..bfc47c6bd 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -35,6 +35,7 @@ export function TimelineEditProvider({ value.onMoveElements, value.onResizeElements, value.onPreviewMoveElements, + value.onPreviewResizeElements, value.onToggleTrackHidden, value.onToggleElementHidden, value.onBlockedEditAttempt, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index a9cca4c84..5e9d34a59 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -72,7 +72,9 @@ export const Timeline = memo(function Timeline({ onMoveElement, onResizeElement, onMoveElements, + onResizeElements, onPreviewMoveElements, + onPreviewResizeElements, onBlockedEditAttempt, onSplitElement, onRazorSplitAll, @@ -112,7 +114,6 @@ export const Timeline = memo(function Timeline({ const isDragging = useRef(false); const [shiftHeld, setShiftHeld] = useState(false); const [razorGuideX, setRazorGuideX] = useState(null); - useMountEffect(() => { const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true); const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false); @@ -138,7 +139,6 @@ export const Timeline = memo(function Timeline({ const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); - const syncShortcutHintVisibility = useCallback(() => { const scroll = scrollRef.current; setShowShortcutHint( @@ -223,7 +223,9 @@ export const Timeline = memo(function Timeline({ onMoveElement, onResizeElement, onMoveElements, + onResizeElements, onPreviewMoveElements, + onPreviewResizeElements, onBlockedEditAttempt, setShowPopover, setRangeSelectionRef, diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index a6c19fec9..47e2d83f9 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -48,6 +48,7 @@ export interface TimelineEditCallbacks { options?: TimelineGroupCommitOptions, ) => Promise | void; onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void; + onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void; onToggleTrackHidden?: (track: number, hidden: boolean) => Promise | void; onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts new file mode 100644 index 000000000..f537ab9ab --- /dev/null +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -0,0 +1,52 @@ +import type { TimelineElement } from "../store/playerStore"; +import type { TimelineStackingReorderIntent } from "./timelineEditing"; +import type { TimelineLayerId } from "./timelineTrackOrder"; + +interface DragPreviewState { + element: Pick; + previewLayerId: TimelineLayerId; + previewLayerIndex: number; +} + +export interface TimelineMovePreview { + start: number; + track: number; + previewLayerId?: TimelineLayerId; + previewLayerIndex?: number; + stackingReorder?: TimelineStackingReorderIntent | null; +} + +export interface TimelineGroupMovePreview { + active: boolean; + previewStart: number; +} + +export function resolveDragPreviewPlacement( + drag: DragPreviewState, + nextMove: TimelineMovePreview, + groupMove: TimelineGroupMovePreview, +): { + previewStart: number; + previewTrack: number; + previewLayerId: TimelineLayerId; + previewLayerIndex: number; + previewStackingReorder: TimelineStackingReorderIntent | null; +} { + if (groupMove.active) { + return { + previewStart: groupMove.previewStart, + previewTrack: drag.element.track, + previewLayerId: drag.previewLayerId, + previewLayerIndex: drag.previewLayerIndex, + previewStackingReorder: null, + }; + } + + return { + previewStart: groupMove.previewStart, + previewTrack: nextMove.track, + previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId, + previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex, + previewStackingReorder: nextMove.stackingReorder ?? null, + }; +} diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index eaf7e9a76..86cbb1c8b 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -4,6 +4,7 @@ import { buildPromptCopyText, buildTimelineElementAgentPrompt, buildTimelineAgentPrompt, + clampTimelineGroupResizeDelta, getTimelineEditCapabilities, hasPatchableTimelineTarget, resolveBlockedTimelineEditIntent, @@ -11,6 +12,7 @@ import { resolveTimelineMove, resolveTimelineResize, resolveTimelineGroupMove, + resolveTimelineGroupResize, snapKeyframePctToBeat, type TimelinePromptElement, } from "./timelineEditing"; @@ -260,6 +262,120 @@ describe("resolveTimelineGroupMove", () => { }); }); +describe("resolveTimelineGroupResize", () => { + it("returns the shared clamped delta without applying per-member starts", () => { + expect( + clampTimelineGroupResizeDelta( + 1, + [ + { start: 1, duration: 0.5 }, + { start: 4, duration: 2 }, + ], + "start", + ), + ).toBe(0.4); + }); + + it("applies an unclamped start-edge delta uniformly", () => { + const result = resolveTimelineGroupResize( + [ + { start: 1, duration: 3 }, + { start: 5, duration: 4 }, + ], + "start", + 1, + ); + + expect(result).toEqual({ + delta: 1, + members: [ + { start: 2, duration: 2, playbackStart: undefined }, + { start: 6, duration: 3, playbackStart: undefined }, + ], + }); + expect(result.members[1]!.start - result.members[0]!.start).toBe(4); + }); + + it("clamps a start-edge delta when the earliest member reaches zero", () => { + const result = resolveTimelineGroupResize( + [ + { start: 0.5, duration: 3 }, + { start: 4, duration: 4 }, + ], + "start", + -2, + ); + + expect(result).toEqual({ + delta: -0.5, + members: [ + { start: 0, duration: 3.5, playbackStart: undefined }, + { start: 3.5, duration: 4.5, playbackStart: undefined }, + ], + }); + expect(result.members[1]!.start - result.members[0]!.start).toBe(3.5); + }); + + it("clamps a start-edge delta when any member reaches minimum duration", () => { + const result = resolveTimelineGroupResize( + [ + { start: 1, duration: 0.5 }, + { start: 4, duration: 2 }, + ], + "start", + 1, + ); + + expect(result).toEqual({ + delta: 0.4, + members: [ + { start: 1.4, duration: 0.1, playbackStart: undefined }, + { start: 4.4, duration: 1.6, playbackStart: undefined }, + ], + }); + expect(result.members[1]!.start - result.members[0]!.start).toBeCloseTo(3); + }); + + it("clamps an end-edge delta when any member reaches minimum duration", () => { + const result = resolveTimelineGroupResize( + [ + { start: 1, duration: 0.5 }, + { start: 4, duration: 2 }, + ], + "end", + -1, + ); + + expect(result).toEqual({ + delta: -0.4, + members: [ + { start: 1, duration: 0.1, playbackStart: undefined }, + { start: 4, duration: 1.6, playbackStart: undefined }, + ], + }); + expect(result.members[1]!.start - result.members[0]!.start).toBe(3); + }); + + it("adjusts each start-edge playback start using the shared delta", () => { + const result = resolveTimelineGroupResize( + [ + { start: 2, duration: 3, playbackStart: 1, playbackRate: 1 }, + { start: 5, duration: 4, playbackStart: 2, playbackRate: 2 }, + ], + "start", + 0.5, + ); + + expect(result).toEqual({ + delta: 0.5, + members: [ + { start: 2.5, duration: 2.5, playbackStart: 1.5 }, + { start: 5.5, duration: 3.5, playbackStart: 3 }, + ], + }); + }); +}); + describe("hasPatchableTimelineTarget", () => { it("returns true when the clip has a DOM id", () => { expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true); diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index e92d914c3..0f07e072f 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -4,7 +4,15 @@ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrde import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag"; import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking"; -export { resolveTimelineGroupMove, type TimelineGroupTimingMember } from "./timelineGroupEditing"; +import { resolveTimelineMinDuration } from "./timelineGroupEditing"; + +export { + clampTimelineGroupResizeDelta, + resolveTimelineGroupMove, + resolveTimelineGroupResize, + type TimelineGroupResizeEdge, + type TimelineGroupTimingMember, +} from "./timelineGroupEditing"; export { type TimelineStackingElement, @@ -196,7 +204,7 @@ export function resolveTimelineResize( edge: "start" | "end", clientX: number, ): { start: number; duration: number; playbackStart?: number } { - const minDuration = Math.max(0.05, input.minDuration ?? 0.1); + const minDuration = resolveTimelineMinDuration(input.minDuration); const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1); if (edge === "end") { diff --git a/packages/studio/src/player/components/timelineGroupEditing.ts b/packages/studio/src/player/components/timelineGroupEditing.ts index 3d35414f2..11de9c8ed 100644 --- a/packages/studio/src/player/components/timelineGroupEditing.ts +++ b/packages/studio/src/player/components/timelineGroupEditing.ts @@ -1,19 +1,39 @@ import { roundToCenti } from "../../utils/rounding"; +const DEFAULT_TIMELINE_MIN_DURATION = 0.1; +const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + function roundTimelineTime(value: number): number { return roundToCenti(value); } +export function resolveTimelineMinDuration(minDuration?: number): number { + return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION); +} + export interface TimelineGroupTimingMember { start: number; duration: number; + playbackStart?: number; + playbackRate?: number; } +export type TimelineGroupResizeEdge = "start" | "end"; + export interface TimelineGroupMoveResult { delta: number; members: Array>; } +export interface TimelineGroupResizeResult { + delta: number; + members: Array>; +} + function clampTimelineGroupMoveDelta( rawDelta: number, members: readonly TimelineGroupTimingMember[], @@ -36,3 +56,61 @@ export function resolveTimelineGroupMove( })), }; } + +export function clampTimelineGroupResizeDelta( + rawDelta: number, + members: readonly TimelineGroupTimingMember[], + edge: TimelineGroupResizeEdge, + minDuration = resolveTimelineMinDuration(), +): number { + if (members.length === 0) return 0; + + if (edge === "end") { + const minDelta = Math.max(...members.map((member) => minDuration - member.duration)); + return roundTimelineTime(Math.max(rawDelta, minDelta)); + } + + const minDelta = Math.max( + ...members.map((member) => { + const playbackRate = Math.max(0.1, member.playbackRate ?? 1); + const maxLeftExtensionFromMedia = + member.playbackStart != null + ? member.playbackStart / playbackRate + : Number.POSITIVE_INFINITY; + return -Math.min(member.start, maxLeftExtensionFromMedia); + }), + ); + const maxDelta = Math.min(...members.map((member) => member.duration - minDuration)); + return roundTimelineTime(clamp(rawDelta, minDelta, maxDelta)); +} + +export function resolveTimelineGroupResize( + members: readonly TimelineGroupTimingMember[], + edge: TimelineGroupResizeEdge, + rawDelta: number, + minDuration = resolveTimelineMinDuration(), +): TimelineGroupResizeResult { + const delta = clampTimelineGroupResizeDelta(rawDelta, members, edge, minDuration); + return { + delta, + members: members.map((member) => { + if (edge === "end") { + return { + start: member.start, + duration: roundTimelineTime(member.duration + delta), + playbackStart: member.playbackStart, + }; + } + + const playbackRate = Math.max(0.1, member.playbackRate ?? 1); + return { + start: roundTimelineTime(member.start + delta), + duration: roundTimelineTime(member.duration - delta), + playbackStart: + member.playbackStart != null + ? roundTimelineTime(Math.max(0, member.playbackStart + delta * playbackRate)) + : undefined, + }; + }), + }; +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index e635339aa..98973e9a2 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -55,7 +55,9 @@ function renderDragHarness(elements: TimelineElement[]) { const onMoveElement = vi.fn(); const onResizeElement = vi.fn(); const onMoveElements = vi.fn(); + const onResizeElements = vi.fn(); const onPreviewMoveElements = vi.fn(); + const onPreviewResizeElements = vi.fn(); let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null; let setResizingClip: ((state: ResizingClipState | null) => void) | null = null; @@ -69,7 +71,9 @@ function renderDragHarness(elements: TimelineElement[]) { onMoveElement, onResizeElement, onMoveElements, + onResizeElements, onPreviewMoveElements, + onPreviewResizeElements, onBlockedEditAttempt: vi.fn(), setShowPopover: vi.fn(), setRangeSelectionRef: { current: vi.fn() }, @@ -95,7 +99,9 @@ function renderDragHarness(elements: TimelineElement[]) { onMoveElement, onResizeElement, onMoveElements, + onResizeElements, onPreviewMoveElements, + onPreviewResizeElements, storeElements() { return usePlayerStore.getState().elements; }, @@ -275,6 +281,120 @@ describe("useTimelineClipDrag", () => { harness.unmount(); }); + it("resizes every selected start edge by the same delta", async () => { + const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 }); + const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 }); + const harness = renderDragHarness([first, second]); + act(() => { + usePlayerStore.getState().setSelection(["first", "second"], "first"); + }); + + harness.startResize(first, "start"); + harness.movePointer(100, 0); + + expect( + harness.storeElements().map((element) => [element.id, element.start, element.duration]), + ).toEqual([ + ["first", 2, 3], + ["second", 6, 2], + ]); + expect(harness.onPreviewResizeElements).toHaveBeenLastCalledWith([ + { element: first, start: 2, duration: 3, playbackStart: undefined }, + { element: second, start: 6, duration: 2, playbackStart: undefined }, + ]); + + await harness.dropPointer(); + + expect(harness.onResizeElement).not.toHaveBeenCalled(); + expect(harness.onResizeElements).toHaveBeenCalledTimes(1); + expect(harness.onResizeElements).toHaveBeenCalledWith([ + { element: first, start: 2, duration: 3, playbackStart: undefined }, + { element: second, start: 6, duration: 2, playbackStart: undefined }, + ]); + + harness.unmount(); + }); + + it("resizes every selected end edge by the same delta", async () => { + const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 }); + const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 }); + const harness = renderDragHarness([first, second]); + act(() => { + usePlayerStore.getState().setSelection(["first", "second"], "first"); + }); + + harness.startResize(first, "end"); + harness.movePointer(100, 0); + await harness.dropPointer(); + + expect(harness.onResizeElement).not.toHaveBeenCalled(); + expect(harness.onResizeElements).toHaveBeenCalledWith([ + { element: first, start: 1, duration: 5, playbackStart: undefined }, + { element: second, start: 5, duration: 4, playbackStart: undefined }, + ]); + + harness.unmount(); + }); + + it("clamps selected start-edge resize at the most constrained duration", async () => { + const short = timelineElement({ id: "short", track: 0, zIndex: 1, start: 1, duration: 0.5 }); + const long = timelineElement({ id: "long", track: 1, zIndex: 1, start: 4, duration: 2 }); + const harness = renderDragHarness([short, long]); + act(() => { + usePlayerStore.getState().setSelection(["short", "long"], "short"); + }); + + harness.startResize(short, "start"); + harness.movePointer(100, 0); + await harness.dropPointer(); + + expect(harness.onResizeElements).toHaveBeenCalledWith([ + { element: short, start: 1.4, duration: 0.1, playbackStart: undefined }, + { element: long, start: 4.4, duration: 1.6, playbackStart: undefined }, + ]); + expect(harness.onResizeElement).not.toHaveBeenCalled(); + + harness.unmount(); + }); + + it("adjusts every selected media playback start during start-edge resize", async () => { + const audio = timelineElement({ + id: "audio", + tag: "audio", + track: 0, + zIndex: 1, + start: 2, + duration: 3, + playbackStart: 1, + playbackRate: 1, + }); + const video = timelineElement({ + id: "video", + tag: "video", + track: 1, + zIndex: 1, + start: 5, + duration: 4, + playbackStart: 2, + playbackRate: 2, + }); + const harness = renderDragHarness([audio, video]); + act(() => { + usePlayerStore.getState().setSelection(["audio", "video"], "audio"); + }); + + harness.startResize(audio, "start"); + harness.movePointer(50, 0); + await harness.dropPointer(); + + expect(harness.onResizeElements).toHaveBeenCalledWith([ + { element: audio, start: 2.5, duration: 2.5, playbackStart: 1.5 }, + { element: video, start: 5.5, duration: 3.5, playbackStart: 3 }, + ]); + + harness.unmount(); + }); + it("keeps handle resize single-clip when the grabbed clip is not in the multi-selection", async () => { const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 }); const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 634711c88..9850bff8a 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -13,13 +13,17 @@ import { TRACK_H } from "./timelineLayout"; import { isMusicTrack } from "../../utils/timelineInspector"; import { mergeUserBeats } from "../../utils/beatEditing"; import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; -import type { TimelineGroupMoveChange } from "../../hooks/useTimelineGroupEditing"; +import type { + TimelineGroupMoveChange, + TimelineGroupResizeChange, +} from "../../hooks/useTimelineGroupEditing"; import { buildTimelineSnapTargets, snapEdgesToTargets, snapResizeEdgeToTargets, type TimelineSnapKind, } from "./timelineSnapTargets"; +import { resolveDragPreviewPlacement } from "./timelineClipDragPreview"; import { useTimelineClipGroupDrag } from "./useTimelineClipGroupDrag"; const EMPTY_BEAT_TIMES: number[] = []; @@ -86,57 +90,15 @@ interface UseTimelineClipDragInput { updates: Pick, ) => Promise | void; onMoveElements?: (changes: TimelineGroupMoveChange[]) => Promise | void; + onResizeElements?: (changes: TimelineGroupResizeChange[]) => Promise | void; onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void; + onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; setShowPopover: (show: boolean) => void; /** Stable ref to the range selection setter, wired after mount to break circular dependency. */ setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>; } -interface TimelineMovePreview { - start: number; - track: number; - previewLayerId?: TimelineLayerId; - previewLayerIndex?: number; - stackingReorder?: TimelineStackingReorderIntent | null; -} - -interface TimelineGroupMovePreview { - active: boolean; - previewStart: number; -} - -function resolveDragPreviewPlacement( - drag: DraggedClipState, - nextMove: TimelineMovePreview, - groupMove: TimelineGroupMovePreview, -): Pick< - DraggedClipState, - | "previewStart" - | "previewTrack" - | "previewLayerId" - | "previewLayerIndex" - | "previewStackingReorder" -> { - if (groupMove.active) { - return { - previewStart: groupMove.previewStart, - previewTrack: drag.element.track, - previewLayerId: drag.previewLayerId, - previewLayerIndex: drag.previewLayerIndex, - previewStackingReorder: null, - }; - } - - return { - previewStart: groupMove.previewStart, - previewTrack: nextMove.track, - previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId, - previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex, - previewStackingReorder: nextMove.stackingReorder ?? null, - }; -} - export function useTimelineClipDrag({ scrollRef, ppsRef, @@ -146,7 +108,9 @@ export function useTimelineClipDrag({ onMoveElement, onResizeElement, onMoveElements, + onResizeElements, onPreviewMoveElements, + onPreviewResizeElements, onBlockedEditAttempt, setShowPopover, setRangeSelectionRef, @@ -218,18 +182,30 @@ export function useTimelineClipDrag({ onResizeElementRef.current = onResizeElement; const onMoveElementsRef = useRef(onMoveElements); onMoveElementsRef.current = onMoveElements; + const onResizeElementsRef = useRef(onResizeElements); + onResizeElementsRef.current = onResizeElements; const onPreviewMoveElementsRef = useRef(onPreviewMoveElements); onPreviewMoveElementsRef.current = onPreviewMoveElements; + const onPreviewResizeElementsRef = useRef(onPreviewResizeElements); + onPreviewResizeElementsRef.current = onPreviewResizeElements; const selectedElementIdsRef = useRef(usePlayerStore.getState().selectedElementIds); selectedElementIdsRef.current = usePlayerStore((s) => s.selectedElementIds); const clipDragScrollRaf = useRef(0); const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); - const { previewGroupMove, commitGroupMove, clearGroupDragSessions } = useTimelineClipGroupDrag({ + const { + previewGroupMove, + previewGroupResize, + commitGroupMove, + commitGroupResize, + clearGroupDragSessions, + } = useTimelineClipGroupDrag({ timelineElementsRef, updateElement, onMoveElementsRef, + onResizeElementsRef, onPreviewMoveElementsRef, + onPreviewResizeElementsRef, }); // fallow-ignore-next-line complexity @@ -348,6 +324,8 @@ export function useTimelineClipDrag({ updateDraggedClipPreviewRef.current = updateDraggedClipPreview; const commitGroupMoveRef = useRef(commitGroupMove); commitGroupMoveRef.current = commitGroupMove; + const commitGroupResizeRef = useRef(commitGroupResize); + commitGroupResizeRef.current = commitGroupResize; const clearGroupDragSessionsRef = useRef(clearGroupDragSessions); clearGroupDragSessionsRef.current = clearGroupDragSessions; const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll); @@ -433,6 +411,15 @@ export function useTimelineClipDrag({ }; } } + const groupResize = previewGroupResize( + resize.element, + selectedElementIdsRef.current, + resize.edge, + nextResize, + ); + if (groupResize.active) { + nextResize = groupResize.updates; + } setResizingClip((prev) => prev ? { @@ -493,6 +480,8 @@ export function useTimelineClipDrag({ suppressClickRef.current = true; clearSuppressedClick(); + if (commitGroupResizeRef.current(resize.element)) return; + const hasChanged = resize.previewStart !== resize.element.start || resize.previewDuration !== resize.element.duration || diff --git a/packages/studio/src/player/components/useTimelineClipGroupDrag.ts b/packages/studio/src/player/components/useTimelineClipGroupDrag.ts index e8f691fad..469ab80d3 100644 --- a/packages/studio/src/player/components/useTimelineClipGroupDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipGroupDrag.ts @@ -1,11 +1,21 @@ import { useCallback, useRef, type RefObject } from "react"; -import type { TimelineGroupMoveChange } from "../../hooks/useTimelineGroupEditing"; +import type { + TimelineGroupMoveChange, + TimelineGroupResizeChange, +} from "../../hooks/useTimelineGroupEditing"; import type { TimelineElement } from "../store/playerStore"; -import { resolveTimelineGroupMove, type TimelineGroupTimingMember } from "./timelineEditing"; +import { + resolveTimelineGroupMove, + resolveTimelineGroupResize, + type TimelineGroupResizeEdge, + type TimelineGroupTimingMember, +} from "./timelineEditing"; + +type TimelineResizeUpdates = Pick; type UpdateTimelineElement = ( elementId: string, - updates: Partial>, + updates: Partial>, ) => void; interface GroupTimingMember extends TimelineGroupTimingMember { @@ -20,13 +30,27 @@ interface MoveSession { hasChanged: boolean; } +interface ResizeSession { + grabbedKey: string; + edge: TimelineGroupResizeEdge; + members: GroupTimingMember[]; + changes: TimelineGroupResizeChange[]; + hasChanged: boolean; +} + interface UseTimelineClipGroupDragInput { timelineElementsRef: RefObject; updateElement: UpdateTimelineElement; onMoveElementsRef: RefObject< ((changes: TimelineGroupMoveChange[]) => Promise | void) | undefined >; + onResizeElementsRef: RefObject< + ((changes: TimelineGroupResizeChange[]) => Promise | void) | undefined + >; onPreviewMoveElementsRef: RefObject<((changes: TimelineGroupMoveChange[]) => void) | undefined>; + onPreviewResizeElementsRef: RefObject< + ((changes: TimelineGroupResizeChange[]) => void) | undefined + >; } interface PreviewGroupMoveResult { @@ -34,31 +58,61 @@ interface PreviewGroupMoveResult { previewStart: number; } +interface PreviewGroupResizeResult { + active: boolean; + updates: TimelineResizeUpdates; +} + function elementKey(element: TimelineElement): string { return element.key ?? element.id; } +function isMediaElement(element: TimelineElement): boolean { + const normalizedTag = element.tag.toLowerCase(); + return normalizedTag === "audio" || normalizedTag === "video"; +} + +function selectedElementSet(selectedElementIdsInput: Set): Set { + return selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set(); +} + function selectedMembers( grabbedElement: TimelineElement, selectedElementIdsInput: Set, timelineElements: readonly TimelineElement[], + mapMember: (element: TimelineElement) => GroupTimingMember, ): GroupTimingMember[] | null { - const selectedElementIds = - selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set(); + const selectedElementIds = selectedElementSet(selectedElementIdsInput); const grabbedKey = elementKey(grabbedElement); if (selectedElementIds.size <= 1 || !selectedElementIds.has(grabbedKey)) return null; const members = timelineElements .filter((element) => selectedElementIds.has(elementKey(element))) - .map((element) => ({ - element, - key: elementKey(element), - start: element.start, - duration: element.duration, - })); + .map(mapMember); return members.length > 1 ? members : null; } +function moveMember(element: TimelineElement): GroupTimingMember { + return { + element, + key: elementKey(element), + start: element.start, + duration: element.duration, + }; +} + +function resizeMember(edge: TimelineGroupResizeEdge, element: TimelineElement): GroupTimingMember { + const shouldSeedPlaybackStart = edge === "start" && isMediaElement(element); + return { + element, + key: elementKey(element), + start: element.start, + duration: element.duration, + playbackStart: shouldSeedPlaybackStart ? (element.playbackStart ?? 0) : element.playbackStart, + playbackRate: element.playbackRate, + }; +} + function sameGesture(sessionKey: string, element: TimelineElement): boolean { return sessionKey === elementKey(element); } @@ -68,7 +122,7 @@ function createMoveSession( selectedElementIds: Set, timelineElements: readonly TimelineElement[], ): MoveSession | null { - const members = selectedMembers(element, selectedElementIds, timelineElements); + const members = selectedMembers(element, selectedElementIds, timelineElements, moveMember); if (!members) return null; return { grabbedKey: elementKey(element), @@ -78,6 +132,25 @@ function createMoveSession( }; } +function createResizeSession( + element: TimelineElement, + selectedElementIds: Set, + timelineElements: readonly TimelineElement[], + edge: TimelineGroupResizeEdge, +): ResizeSession | null { + const members = selectedMembers(element, selectedElementIds, timelineElements, (member) => + resizeMember(edge, member), + ); + if (!members) return null; + return { + grabbedKey: elementKey(element), + edge, + members, + changes: [], + hasChanged: false, + }; +} + function resolveMoveChanges( session: MoveSession, previewStart: number, @@ -91,6 +164,29 @@ function resolveMoveChanges( })); } +function resizeRawDelta(session: ResizeSession, updates: TimelineResizeUpdates): number | null { + const grabbed = session.members.find((member) => member.key === session.grabbedKey); + if (!grabbed) return null; + return session.edge === "start" + ? updates.start - grabbed.start + : updates.duration - grabbed.duration; +} + +function resolveResizeChanges( + session: ResizeSession, + updates: TimelineResizeUpdates, +): TimelineGroupResizeChange[] | null { + const rawDelta = resizeRawDelta(session, updates); + if (rawDelta == null) return null; + const result = resolveTimelineGroupResize(session.members, session.edge, rawDelta); + return result.members.map((member, index) => ({ + element: session.members[index]!.element, + start: member.start, + duration: member.duration, + playbackStart: member.playbackStart, + })); +} + function moveSessionHasChanged( session: MoveSession, changes: readonly TimelineGroupMoveChange[], @@ -98,6 +194,20 @@ function moveSessionHasChanged( return changes.some((change, index) => change.start !== session.members[index]!.start); } +function resizeSessionHasChanged( + session: ResizeSession, + changes: readonly TimelineGroupResizeChange[], +): boolean { + return changes.some((change, index) => { + const member = session.members[index]!; + return ( + change.start !== member.start || + change.duration !== member.duration || + change.playbackStart !== member.playbackStart + ); + }); +} + function previewStartForGrabbed( session: MoveSession, changes: readonly TimelineGroupMoveChange[], @@ -107,13 +217,30 @@ function previewStartForGrabbed( return change?.start ?? fallback; } +function resizeUpdatesForGrabbed( + session: ResizeSession, + changes: readonly TimelineGroupResizeChange[], + fallback: TimelineResizeUpdates, +): TimelineResizeUpdates { + const change = changes.find((candidate) => elementKey(candidate.element) === session.grabbedKey); + if (!change) return fallback; + return { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }; +} + export function useTimelineClipGroupDrag({ timelineElementsRef, updateElement, onMoveElementsRef, + onResizeElementsRef, onPreviewMoveElementsRef, + onPreviewResizeElementsRef, }: UseTimelineClipGroupDragInput) { const moveSessionRef = useRef(null); + const resizeSessionRef = useRef(null); const rollbackMove = useCallback( (session: MoveSession) => { @@ -129,6 +256,26 @@ export function useTimelineClipGroupDrag({ [onPreviewMoveElementsRef, updateElement], ); + const rollbackResize = useCallback( + (session: ResizeSession) => { + const changes = session.members.map((member) => ({ + element: member.element, + start: member.start, + duration: member.duration, + playbackStart: member.playbackStart, + })); + for (const change of changes) { + updateElement(elementKey(change.element), { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }); + } + onPreviewResizeElementsRef.current?.(changes); + }, + [onPreviewResizeElementsRef, updateElement], + ); + const previewGroupMove = useCallback( ( element: TimelineElement, @@ -161,6 +308,48 @@ export function useTimelineClipGroupDrag({ [onMoveElementsRef, onPreviewMoveElementsRef, timelineElementsRef, updateElement], ); + const previewGroupResize = useCallback( + ( + element: TimelineElement, + selectedElementIds: Set, + edge: TimelineGroupResizeEdge, + updates: TimelineResizeUpdates, + ): PreviewGroupResizeResult => { + let session = resizeSessionRef.current; + if (!session || !sameGesture(session.grabbedKey, element) || session.edge !== edge) { + if (!onResizeElementsRef.current) return { active: false, updates }; + session = createResizeSession( + element, + selectedElementIds, + timelineElementsRef.current, + edge, + ); + if (!session) return { active: false, updates }; + resizeSessionRef.current = session; + } + + const changes = resolveResizeChanges(session, updates); + if (!changes) return { active: false, updates }; + session.changes = changes; + session.hasChanged = resizeSessionHasChanged(session, changes); + + for (const change of changes) { + updateElement(elementKey(change.element), { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }); + } + onPreviewResizeElementsRef.current?.(changes); + + return { + active: true, + updates: resizeUpdatesForGrabbed(session, changes, updates), + }; + }, + [onPreviewResizeElementsRef, onResizeElementsRef, timelineElementsRef, updateElement], + ); + const commitGroupMove = useCallback( (element: TimelineElement): boolean => { const session = moveSessionRef.current; @@ -177,13 +366,32 @@ export function useTimelineClipGroupDrag({ [onMoveElementsRef, rollbackMove], ); + const commitGroupResize = useCallback( + (element: TimelineElement): boolean => { + const session = resizeSessionRef.current; + if (!session || !sameGesture(session.grabbedKey, element)) return false; + resizeSessionRef.current = null; + if (!session.hasChanged) return true; + + Promise.resolve(onResizeElementsRef.current?.(session.changes)).catch((error) => { + rollbackResize(session); + console.error("[Timeline] Failed to persist group clip resize", error); + }); + return true; + }, + [onResizeElementsRef, rollbackResize], + ); + const clearGroupDragSessions = useCallback(() => { moveSessionRef.current = null; + resizeSessionRef.current = null; }, []); return { previewGroupMove, + previewGroupResize, commitGroupMove, + commitGroupResize, clearGroupDragSessions, }; }