From 5e3ca6ae63d3c6484a4e5cde0eea4e3987f8236a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 17:06:02 -0400 Subject: [PATCH] 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. --- .../src/hooks/timelineEditingHelpers.test.ts | 22 +++- .../src/hooks/timelineEditingHelpers.ts | 51 +++++++- .../src/hooks/useTimelineEditing.test.tsx | 111 +++++++++++++++++- .../studio/src/hooks/useTimelineEditing.ts | 37 ++---- .../studio/src/player/components/Timeline.tsx | 28 +++-- .../components/useTimelineClipDrag.test.tsx | 70 ++++++++++- .../player/components/useTimelineClipDrag.ts | 10 +- packages/studio/src/utils/blockInstaller.ts | 16 +-- .../studio/src/utils/rootDuration.test.ts | 34 ++++++ packages/studio/src/utils/rootDuration.ts | 17 +++ 10 files changed, 327 insertions(+), 69 deletions(-) create mode 100644 packages/studio/src/utils/rootDuration.test.ts create mode 100644 packages/studio/src/utils/rootDuration.ts diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index e120fe2d0..5af7db737 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -1,7 +1,12 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from "vitest"; -import { applyTimelineStackingReorder } from "./timelineEditingHelpers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers"; import type { TimelineElement } from "../player/store/playerStore"; +import { usePlayerStore } from "../player/store/playerStore"; + +afterEach(() => { + usePlayerStore.getState().reset(); +}); function makeIframeWith(html: string): HTMLIFrameElement { const iframe = document.createElement("iframe"); @@ -86,3 +91,16 @@ describe("applyTimelineStackingReorder", () => { expect(commit).not.toHaveBeenCalled(); }); }); + +describe("extendRootDurationIfNeeded", () => { + it("extends the player duration only when the new end is larger", () => { + usePlayerStore.getState().setDuration(4); + + expect(extendRootDurationIfNeeded(5)).toBe(true); + expect(usePlayerStore.getState().duration).toBe(5); + + expect(extendRootDurationIfNeeded(5)).toBe(false); + expect(extendRootDurationIfNeeded(3)).toBe(false); + expect(usePlayerStore.getState().duration).toBe(5); + }); +}); diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 2dfe41b81..a47fe9228 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -10,6 +10,7 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; import type { EditHistoryKind } from "../utils/editHistory"; import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes"; +import { extendRootDurationInSource } from "../utils/rootDuration"; function isHTMLElement(element: Element | null): element is HTMLElement { if (!element) return false; @@ -114,6 +115,13 @@ export function deleteSelectedKeyframes(session: { } } +export function extendRootDurationIfNeeded(newEnd: number): boolean { + const store = usePlayerStore.getState(); + if (newEnd <= store.duration) return false; + store.setDuration(newEnd); + return true; +} + // ── Types ── export interface RecordEditInput { @@ -183,7 +191,7 @@ export function patchIframeDomTiming( } // fallow-ignore-next-line complexity -export function resolveResizePlaybackStart( +function resolveResizePlaybackStart( original: string, target: PatchTarget, element: TimelineElement, @@ -209,6 +217,47 @@ export function resolveResizePlaybackStart( }; } +export function buildTimelineMoveTimingPatch( + original: string, + target: PatchTarget, + start: number, + duration: number, +): string { + const patched = applyPatchByTarget(original, target, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(start), + }); + return extendRootDurationInSource(patched, start + duration); +} + +export function buildTimelineResizeTimingPatch( + original: string, + target: PatchTarget, + element: TimelineElement, + updates: Pick, +): string { + const pbs = resolveResizePlaybackStart(original, target, element, updates); + let patched = applyPatchByTarget(original, target, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(updates.start), + }); + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "duration", + value: formatTimelineAttributeNumber(updates.duration), + }); + if (pbs) { + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: pbs.attrName, + value: formatTimelineAttributeNumber(pbs.value), + }); + } + return extendRootDurationInSource(patched, updates.start + updates.duration); +} + export interface PersistTimelineEditInput { projectId: string; element: TimelineElement; diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index b26460fcb..bb159900f 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -2,8 +2,9 @@ import React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; +import { openComposition } from "@hyperframes/sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { TimelineElement } from "../player"; +import { usePlayerStore, type TimelineElement } from "../player"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { useTimelineEditing } from "./useTimelineEditing"; @@ -20,6 +21,7 @@ type ZIndexEntry = { afterEach(() => { document.body.innerHTML = ""; + usePlayerStore.getState().reset(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -85,11 +87,15 @@ function renderTimelineEditingHook(input: { files: Record; }) => Promise; reloadPreview?: () => void; + sdkSession?: Awaited> | null; + forceReloadSdkSession?: () => void; }): { move: ReturnType["handleTimelineElementMove"]; + resize: ReturnType["handleTimelineElementResize"]; unmount: () => void; } { let move: ReturnType["handleTimelineElementMove"] | null = null; + let resize: ReturnType["handleTimelineElementResize"] | null = null; function Harness() { const commitRef = useRef(input.onZIndexCommit); @@ -106,9 +112,12 @@ function renderTimelineEditingHook(input: { previewIframeRef: { current: input.iframe }, pendingTimelineEditPathRef: { current: new Set() }, uploadProjectFiles: vi.fn(), + sdkSession: input.sdkSession, + forceReloadSdkSession: input.forceReloadSdkSession, handleDomZIndexReorderCommitRef: commitRef, }); move = hook.handleTimelineElementMove; + resize = hook.handleTimelineElementResize; return null; } @@ -120,8 +129,10 @@ function renderTimelineEditingHook(input: { }); if (!move) throw new Error("Expected hook to expose move handler"); + if (!resize) throw new Error("Expected hook to expose resize handler"); return { move, + resize, unmount: () => { act(() => root.unmount()); }, @@ -206,6 +217,104 @@ async function flushAsyncWork(): Promise { } describe("useTimelineEditing timeline z-index reorder", () => { + it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => { + const source = [ + `
`, + `
`, + `
`, + ].join("\n"); + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + const sdkSession = await openComposition(source); + const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async () => {}); + const forceReloadSdkSession = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + usePlayerStore.getState().setDuration(4); + const { move, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn(), + projectId: "p1", + writeProjectFile, + recordEdit, + sdkSession, + forceReloadSdkSession, + }); + + await act(async () => { + await move(clip, { start: 3, track: clip.track }); + }); + + expect(setTimingSpy).not.toHaveBeenCalled(); + expect(writeProjectFile.mock.calls[0]![1]).toContain( + 'data-composition-id="main" data-duration="5"', + ); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"'); + expect(usePlayerStore.getState().duration).toBe(5); + expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => { + const source = [ + `
`, + `
`, + `
`, + ].join("\n"); + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + const sdkSession = await openComposition(source); + const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async () => {}); + const forceReloadSdkSession = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + usePlayerStore.getState().setDuration(4); + const { resize, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn(), + projectId: "p1", + writeProjectFile, + recordEdit, + sdkSession, + forceReloadSdkSession, + }); + + await act(async () => { + await resize(clip, { start: 0, duration: 5, playbackStart: undefined }); + }); + + expect(setTimingSpy).not.toHaveBeenCalled(); + expect(writeProjectFile.mock.calls[0]![1]).toContain( + 'data-composition-id="main" data-duration="5"', + ); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5">'); + expect(usePlayerStore.getState().duration).toBe(5); + expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); + + unmount(); + }); + it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: relative; z-index: 10" }, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index fed47ffbc..835cfa470 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -24,13 +24,14 @@ import { applyTimelineStackingReorder, buildPatchTarget, patchIframeDomTiming, - resolveResizePlaybackStart, persistTimelineEdit, readFileContent, - applyPatchByTarget, formatTimelineAttributeNumber, shiftGsapPositions, scaleGsapPositions, + extendRootDurationIfNeeded, + buildTimelineMoveTimingPatch, + buildTimelineResizeTimingPatch, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing"; @@ -145,11 +146,7 @@ export function useTimelineEditing({ if (!startChanged) return; const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { - return applyPatchByTarget(original, target, { - type: "attribute", - property: "start", - value: formatTimelineAttributeNumber(updates.start), - }); + return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration); }; // Server-path fallback (no SDK session): persist the attr patch, then // shift GSAP tween positions on the server and reload the preview — the @@ -169,7 +166,8 @@ export function useTimelineEditing({ } return reloadPreview(); }); - if (sdkSession && element.hfId) { + const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration); + if (sdkSession && element.hfId && !needsExtension) { return sdkTimingPersist( element.hfId, targetPath, @@ -230,25 +228,7 @@ export function useTimelineEditing({ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); const targetPath = element.sourceFile || activeCompPath || "index.html"; const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { - const pbs = resolveResizePlaybackStart(original, target, element, updates); - let patched = applyPatchByTarget(original, target, { - type: "attribute", - property: "start", - value: formatTimelineAttributeNumber(updates.start), - }); - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: "duration", - value: formatTimelineAttributeNumber(updates.duration), - }); - if (pbs) { - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: pbs.attrName, - value: formatTimelineAttributeNumber(pbs.value), - }); - } - return patched; + return buildTimelineResizeTimingPatch(original, target, element, updates); }; // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field). // The second clause fires because trimming the start of a clip that has a @@ -265,6 +245,7 @@ export function useTimelineEditing({ const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`; const timingChanged = updates.start !== element.start || updates.duration !== element.duration; + const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration); const resizeFallback = () => enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => { const pid = projectIdRef.current; @@ -283,7 +264,7 @@ export function useTimelineEditing({ } return reloadPreview(); }); - if (sdkSession && element.hfId && !hasPbsAdjustment) { + if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) { return sdkTimingPersist( element.hfId, targetPath, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index a43b00f86..91a4b6fa8 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -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 || diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index efe4840da..58d113ba5 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -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(); }); 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 }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 6e45e05ee..f7c51d895 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -114,7 +114,6 @@ export interface BlockedClipState { interface UseTimelineClipDragInput { scrollRef: React.RefObject; ppsRef: React.RefObject; - durationRef: React.RefObject; trackOrderRef: React.RefObject; timelineLayersRef: React.RefObject; timelineElementsRef: React.RefObject; @@ -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, diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index c2ca2bbab..daf636a7f 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -8,6 +8,7 @@ import { collectHtmlIds } from "./studioHelpers"; import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; import { saveProjectFilesWithHistory } from "./studioFileHistory"; import type { EditHistoryKind } from "./editHistory"; +import { extendRootDurationInSource } from "./rootDuration"; function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number { try { @@ -163,20 +164,7 @@ export async function addBlockToProject( ].join("\n"); let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml); - - const newEnd = start + duration; - const rootDurMatch = patchedContent.match( - /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/, - ); - if (rootDurMatch) { - const rootDur = parseFloat(rootDurMatch[2]!); - if (newEnd > rootDur) { - patchedContent = patchedContent.replace( - rootDurMatch[0], - `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`, - ); - } - } + patchedContent = extendRootDurationInSource(patchedContent, start + duration); await saveProjectFilesWithHistory({ projectId, diff --git a/packages/studio/src/utils/rootDuration.test.ts b/packages/studio/src/utils/rootDuration.test.ts new file mode 100644 index 000000000..795941ce5 --- /dev/null +++ b/packages/studio/src/utils/rootDuration.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { extendRootDurationInSource } from "./rootDuration"; + +describe("extendRootDurationInSource", () => { + it("extends data-duration when the new end is bigger than the root duration", () => { + const source = [ + `
`, + `
`, + `
`, + ].join("\n"); + + expect(extendRootDurationInSource(source, 5.25)).toContain( + `data-composition-id="main" data-duration="5.25"`, + ); + }); + + it("does nothing when the new end is smaller than or equal to the root duration", () => { + const source = `
`; + + expect(extendRootDurationInSource(source, 5)).toBe(source); + expect(extendRootDurationInSource(source, 6)).toBe(source); + }); + + it("leaves non-root data-duration attributes untouched by the extension", () => { + const source = [ + `
`, + `
`, + ].join("\n"); + const patched = extendRootDurationInSource(source, 7); + + expect(patched).toContain(`
`); + expect(patched).toContain(`
`); + }); +}); diff --git a/packages/studio/src/utils/rootDuration.ts b/packages/studio/src/utils/rootDuration.ts new file mode 100644 index 000000000..0a9350425 --- /dev/null +++ b/packages/studio/src/utils/rootDuration.ts @@ -0,0 +1,17 @@ +import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; + +export function extendRootDurationInSource(source: string, newEnd: number): string { + const rootDurMatch = source.match( + /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/, + ); + if (rootDurMatch) { + const rootDur = parseFloat(rootDurMatch[2]!); + if (newEnd > rootDur) { + return source.replace( + rootDurMatch[0], + `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`, + ); + } + } + return source; +}