mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix: sequence multi-file timeline drops (#487)
This commit is contained in:
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user