From b4ac293975662b87f2cc6df53131c254e990cb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 24 Apr 2026 23:37:42 -0400 Subject: [PATCH] fix: sequence multi-file timeline drops --- packages/studio/src/App.tsx | 43 ++++++++++-- .../src/utils/timelineAssetDrop.test.ts | 68 +++++++++++++++++-- .../studio/src/utils/timelineAssetDrop.ts | 32 +++++++-- 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 7bd4d9854..4240951d7 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -957,7 +957,11 @@ export function StudioApp() { ); const handleTimelineAssetDrop = useCallback( - async (assetPath: string, placement: Pick) => { + async ( + assetPath: string, + placement: Pick, + durationOverride?: number, + ) => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); @@ -983,9 +987,11 @@ export function StudioApp() { } const normalizedStart = Number(formatTimelineAttributeNumber(placement.start)); - const normalizedDuration = Number( - formatTimelineAttributeNumber(await resolveDroppedAssetDuration(pid, assetPath, kind)), - ); + const duration = + Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0 + ? durationOverride + : await resolveDroppedAssetDuration(pid, assetPath, kind); + const normalizedDuration = Number(formatTimelineAttributeNumber(duration)); const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent)); const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath); @@ -1064,17 +1070,40 @@ export function StudioApp() { const handleTimelineFileDrop = useCallback( async (files: File[], placement?: Pick) => { + const pid = projectIdRef.current; + if (!pid) return; const uploaded = await uploadProjectFiles(files); if (uploaded.length === 0) return; + const durations: number[] = []; + for (const assetPath of uploaded) { + const kind = getTimelineAssetKind(assetPath); + const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0; + durations.push(Number(formatTimelineAttributeNumber(duration))); + } const placements = buildTimelineFileDropPlacements( placement ?? { start: 0, track: 0 }, - uploaded.length, + durations, + timelineElements + .filter( + (timelineElement) => + (timelineElement.sourceFile || activeCompPath || "index.html") === + (activeCompPath || "index.html"), + ) + .map((timelineElement) => ({ + start: timelineElement.start, + duration: timelineElement.duration, + track: timelineElement.track, + })), ); for (const [index, assetPath] of uploaded.entries()) { - await handleTimelineAssetDrop(assetPath, placements[index] ?? placements[0]); + await handleTimelineAssetDrop( + assetPath, + placements[index] ?? placements[0], + durations[index], + ); } }, - [handleTimelineAssetDrop, uploadProjectFiles], + [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles], ); // ── File Management Handlers ── diff --git a/packages/studio/src/utils/timelineAssetDrop.test.ts b/packages/studio/src/utils/timelineAssetDrop.test.ts index e8aad53b7..2284091b4 100644 --- a/packages/studio/src/utils/timelineAssetDrop.test.ts +++ b/packages/studio/src/utils/timelineAssetDrop.test.ts @@ -11,6 +11,8 @@ describe("getTimelineAssetKind", () => { it("detects image, video, and audio assets", () => { expect(getTimelineAssetKind("assets/photo.png")).toBe("image"); expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video"); + expect(getTimelineAssetKind("assets/clip.mov")).toBe("video"); + expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio"); expect(getTimelineAssetKind("assets/music.wav")).toBe("audio"); }); }); @@ -58,11 +60,69 @@ describe("resolveTimelineAssetSrc", () => { }); describe("buildTimelineFileDropPlacements", () => { - it("uses the dropped start and stacks multiple files onto successive tracks", () => { - expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, 3)).toEqual([ + it("returns no placements for an empty drop set", () => { + expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]); + }); + + it("uses the dropped start and spaces multiple files by duration on the same track", () => { + expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([ { start: 1.5, track: 2 }, - { start: 1.5, track: 3 }, - { start: 1.5, track: 4 }, + { start: 2.7, track: 2 }, + { start: 4.3, track: 2 }, + ]); + }); + + it("uses fallback spacing when a duration is unavailable", () => { + expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 0, 1.1])).toEqual([ + { start: 1.5, track: 2 }, + { start: 2.7, track: 2 }, + { start: 7.7, track: 2 }, + ]); + }); + + it("moves the spaced sequence to a clear track when the dropped row is occupied", () => { + expect( + buildTimelineFileDropPlacements( + { start: 1.5, track: 2 }, + [1.2, 1.6, 1.1], + [ + { start: 0, duration: 8, track: 2 }, + { start: 0, duration: 4, track: 5 }, + ], + ), + ).toEqual([ + { start: 1.5, track: 6 }, + { start: 2.7, track: 6 }, + { start: 4.3, track: 6 }, + ]); + }); + + it("keeps a requested track above occupied rows when that track is clear", () => { + expect( + buildTimelineFileDropPlacements( + { start: 1.5, track: 8 }, + [1.2, 1.6], + [ + { start: 0, duration: 8, track: 2 }, + { start: 0, duration: 4, track: 5 }, + ], + ), + ).toEqual([ + { start: 1.5, track: 8 }, + { start: 2.7, track: 8 }, + ]); + }); + + it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => { + expect( + buildTimelineFileDropPlacements( + { start: 0, track: 0 }, + [1.2, 1.6], + [{ start: 0, duration: 8, track: 0 }], + ), + ).toEqual([ + { start: 0, track: 1 }, + { start: 1.2, track: 1 }, ]); }); }); diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts index 9466158fd..c8ee11a55 100644 --- a/packages/studio/src/utils/timelineAssetDrop.ts +++ b/packages/studio/src/utils/timelineAssetDrop.ts @@ -1,6 +1,7 @@ import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes"; export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset"; +const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5; export type TimelineAssetKind = "image" | "video" | "audio"; @@ -46,12 +47,33 @@ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): export function buildTimelineFileDropPlacements( placement: { start: number; track: number }, - count: number, + durations: number[], + occupiedClips: Array<{ start: number; duration: number; track: number }> = [], ): Array<{ start: number; track: number }> { - return Array.from({ length: Math.max(0, count) }, (_, index) => ({ - start: placement.start, - track: placement.track + index, - })); + let nextStart = Math.round(Math.max(0, placement.start) * 100) / 100; + const sequenceStart = nextStart; + const resolvedDurations = durations.map((duration) => + Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION, + ); + const sequenceEnd = resolvedDurations.reduce( + (end, duration) => Math.round((end + duration) * 100) / 100, + sequenceStart, + ); + const overlapsDropTrack = occupiedClips.some((clip) => { + if (clip.track !== placement.track) return false; + const clipStart = Math.max(0, clip.start); + const clipEnd = clipStart + Math.max(0, clip.duration); + return sequenceStart < clipEnd && sequenceEnd > clipStart; + }); + const track = overlapsDropTrack + ? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1 + : placement.track; + + return resolvedDurations.map((duration) => { + const start = nextStart; + nextStart = Math.round((nextStart + duration) * 100) / 100; + return { start, track }; + }); } export function buildTimelineAssetInsertHtml(input: {