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; +}