mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
feat(studio): drag a clip past the video end to extend its duration
Dragging a timeline clip (or its right resize edge) past the end of the video now extends the composition duration on drop, instead of clamping the clip at the current end. Extend-only and undoable. - Relax the move/resize horizontal clamps that pinned a clip's end at the current duration; effectiveDuration now folds in the active drag/resize preview so the ruler and track width grow live as you drag past the end. - On drop, extend the root composition data-duration (and the store) when the clip's new end exceeds it, via a shared extendRootDurationInSource helper extracted from the block installer (now the single owner of that logic). An extending edit routes through the server persist path since the SDK setTiming op can't express the root composition's own duration.
This commit is contained in:
@@ -180,14 +180,6 @@ export const Timeline = memo(function Timeline({
|
||||
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
||||
});
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
const safeDur = Number.isFinite(duration) ? duration : 0;
|
||||
if (rawElements.length === 0) return safeDur;
|
||||
const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
|
||||
const result = Math.max(safeDur, maxEnd);
|
||||
return Number.isFinite(result) ? result : safeDur;
|
||||
}, [rawElements, duration]);
|
||||
|
||||
const tracks = useMemo(
|
||||
() => buildStackingTimelineLayers(expandedElements).rows,
|
||||
[expandedElements],
|
||||
@@ -210,8 +202,7 @@ export const Timeline = memo(function Timeline({
|
||||
expandedElementsRef.current = expandedElements;
|
||||
|
||||
const ppsRef = useRef(100);
|
||||
const durationRef = useRef(effectiveDuration);
|
||||
durationRef.current = effectiveDuration;
|
||||
const durationRef = useRef(Number.isFinite(duration) ? duration : 0);
|
||||
|
||||
// Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep
|
||||
const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null);
|
||||
@@ -227,7 +218,6 @@ export const Timeline = memo(function Timeline({
|
||||
} = useTimelineClipDrag({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
timelineElementsRef: expandedElementsRef,
|
||||
@@ -238,6 +228,22 @@ export const Timeline = memo(function Timeline({
|
||||
setRangeSelectionRef,
|
||||
});
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
const safeDur = Number.isFinite(duration) ? duration : 0;
|
||||
let maxEnd = safeDur;
|
||||
if (rawElements.length > 0) {
|
||||
maxEnd = Math.max(maxEnd, ...rawElements.map((el) => el.start + el.duration));
|
||||
}
|
||||
if (draggedClip?.started) {
|
||||
maxEnd = Math.max(maxEnd, draggedClip.previewStart + draggedClip.element.duration);
|
||||
}
|
||||
if (resizingClip?.started) {
|
||||
maxEnd = Math.max(maxEnd, resizingClip.previewStart + resizingClip.previewDuration);
|
||||
}
|
||||
return Number.isFinite(maxEnd) ? maxEnd : safeDur;
|
||||
}, [rawElements, duration, draggedClip, resizingClip]);
|
||||
durationRef.current = effectiveDuration;
|
||||
|
||||
const displayTrackOrder = useMemo(() => {
|
||||
if (
|
||||
!draggedClip?.started ||
|
||||
|
||||
@@ -7,18 +7,26 @@ import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
|
||||
import type { DraggedClipState } from "./useTimelineClipDrag";
|
||||
import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement {
|
||||
function timelineElement(input: {
|
||||
id: string;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
sourceDuration?: number;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
domId: input.id,
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
start: input.start ?? 0,
|
||||
duration: input.duration ?? 2,
|
||||
sourceDuration: input.sourceDuration,
|
||||
track: input.track,
|
||||
zIndex: input.zIndex,
|
||||
stackingContextId: "root",
|
||||
@@ -39,23 +47,25 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
const scroll = document.createElement("div");
|
||||
document.body.append(scroll);
|
||||
const onMoveElement = vi.fn();
|
||||
const onResizeElement = vi.fn();
|
||||
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
|
||||
let setResizingClip: ((state: ResizingClipState | null) => void) | null = null;
|
||||
|
||||
function Harness() {
|
||||
const hook = useTimelineClipDrag({
|
||||
scrollRef: { current: scroll },
|
||||
ppsRef: { current: 100 },
|
||||
durationRef: { current: 10 },
|
||||
trackOrderRef: { current: layers.map((layer) => layer.id) },
|
||||
timelineLayersRef: { current: layers },
|
||||
timelineElementsRef: { current: elements },
|
||||
onMoveElement,
|
||||
onResizeElement: vi.fn(),
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt: vi.fn(),
|
||||
setShowPopover: vi.fn(),
|
||||
setRangeSelectionRef: { current: vi.fn() },
|
||||
});
|
||||
setDraggedClip = hook.setDraggedClip;
|
||||
setResizingClip = hook.setResizingClip;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -66,11 +76,14 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
if (!setDraggedClip) throw new Error("Expected drag setter");
|
||||
if (!setResizingClip) throw new Error("Expected resize setter");
|
||||
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
|
||||
const applyResizingClip: (state: ResizingClipState | null) => void = setResizingClip;
|
||||
|
||||
return {
|
||||
layers,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
startDrag(element: TimelineElement, layerIndex: number) {
|
||||
act(() => {
|
||||
applyDraggedClip({
|
||||
@@ -93,6 +106,19 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
});
|
||||
});
|
||||
},
|
||||
startResize(element: TimelineElement, edge: "start" | "end") {
|
||||
act(() => {
|
||||
applyResizingClip({
|
||||
element,
|
||||
edge,
|
||||
originClientX: 0,
|
||||
previewStart: element.start,
|
||||
previewDuration: element.duration,
|
||||
previewPlaybackStart: element.playbackStart,
|
||||
started: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
movePointer(clientX: number, clientY: number) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
@@ -116,6 +142,38 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
}
|
||||
|
||||
describe("useTimelineClipDrag", () => {
|
||||
it("allows moving a clip past the current composition duration", async () => {
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1 });
|
||||
const harness = renderDragHarness([clip]);
|
||||
|
||||
harness.startDrag(clip, 0);
|
||||
harness.movePointer(1100, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElement).toHaveBeenCalledWith(
|
||||
clip,
|
||||
expect.objectContaining({ start: 11 }),
|
||||
);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("allows right-edge resize past the current composition duration", async () => {
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 });
|
||||
const harness = renderDragHarness([clip]);
|
||||
|
||||
harness.startResize(clip, "end");
|
||||
harness.movePointer(400, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElement).toHaveBeenCalledWith(
|
||||
clip,
|
||||
expect.objectContaining({ start: 6, duration: 6 }),
|
||||
);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => {
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
||||
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
|
||||
|
||||
@@ -114,7 +114,6 @@ export interface BlockedClipState {
|
||||
interface UseTimelineClipDragInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
ppsRef: React.RefObject<number>;
|
||||
durationRef: React.RefObject<number>;
|
||||
trackOrderRef: React.RefObject<TimelineLayerId[]>;
|
||||
timelineLayersRef: React.RefObject<StackingTimelineLayer[]>;
|
||||
timelineElementsRef: React.RefObject<TimelineElement[]>;
|
||||
@@ -137,7 +136,6 @@ interface UseTimelineClipDragInput {
|
||||
export function useTimelineClipDrag({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
timelineElementsRef,
|
||||
@@ -214,7 +212,7 @@ export function useTimelineClipDrag({
|
||||
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
trackHeight: TRACK_H,
|
||||
maxStart: Math.max(0, durationRef.current - drag.element.duration),
|
||||
maxStart: Number.POSITIVE_INFINITY,
|
||||
trackOrder: timelineLayersRef.current.map((layer) => layer.placementTrack),
|
||||
layerOrder: trackOrderRef.current,
|
||||
timelineLayers: timelineLayersRef.current,
|
||||
@@ -232,7 +230,7 @@ export function useTimelineClipDrag({
|
||||
drag.element.duration,
|
||||
beatTimesRef.current,
|
||||
ppsRef.current,
|
||||
durationRef.current,
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
return {
|
||||
...drag,
|
||||
@@ -247,7 +245,7 @@ export function useTimelineClipDrag({
|
||||
snapBeatTime: snap.beat,
|
||||
};
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, timelineLayersRef, timelineElementsRef],
|
||||
[scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef],
|
||||
);
|
||||
|
||||
const stopClipDragAutoScroll = useCallback(() => {
|
||||
@@ -342,7 +340,7 @@ export function useTimelineClipDrag({
|
||||
const normalizedTag = resize.element.tag.toLowerCase();
|
||||
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
||||
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
||||
const maxEnd = Math.min(durationRef.current, resize.element.start + sourceRemaining);
|
||||
const maxEnd = resize.element.start + sourceRemaining;
|
||||
let nextResize = resolveTimelineResize(
|
||||
{
|
||||
start: resize.element.start,
|
||||
|
||||
Reference in New Issue
Block a user