fix: sequence multi-file timeline drops (#487)

This commit is contained in:
Miguel Ángel
2026-04-25 06:22:52 +02:00
committed by GitHub
parent bb2e64ced1
commit 5e1db03fe7
3 changed files with 127 additions and 16 deletions
+36 -7
View File
@@ -957,7 +957,11 @@ export function StudioApp() {
);
const handleTimelineAssetDrop = useCallback(
async (assetPath: string, placement: Pick<TimelineElement, "start" | "track">) => {
async (
assetPath: string,
placement: Pick<TimelineElement, "start" | "track">,
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<TimelineElement, "start" | "track">) => {
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 ──
@@ -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 },
]);
});
});
+27 -5
View File
@@ -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: {