fix: tighten track placement type safety

This commit is contained in:
Maze Winther 2026-03-31 22:12:50 +02:00
parent 0f5dca4f12
commit 26594546ea
2 changed files with 244 additions and 61 deletions

View File

@ -1,7 +1,53 @@
import { describe, expect, test } from "bun:test";
import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline";
import type {
AudioElement,
AudioTrack,
GraphicElement,
GraphicTrack,
TextElement,
TextTrack,
TimelineTrack,
VideoElement,
VideoTrack,
} from "@/lib/timeline";
import type { Transform } from "@/lib/rendering";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
function buildTransform(): Transform {
return {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
};
}
type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
function buildElement(params: {
id: string;
type: "audio";
startTime: number;
duration: number;
}): AudioElement;
function buildElement(params: {
id: string;
type: "graphic";
startTime: number;
duration: number;
}): GraphicElement;
function buildElement(params: {
id: string;
type: "text";
startTime: number;
duration: number;
}): TextElement;
function buildElement(params: {
id: string;
type: "video";
startTime: number;
duration: number;
}): VideoElement;
function buildElement({
id,
type,
@ -9,61 +55,171 @@ function buildElement({
duration,
}: {
id: string;
type: ElementType;
type: TestElement["type"];
startTime: number;
duration: number;
}): TimelineElement {
return {
id,
type,
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
} as TimelineElement;
}): TestElement {
switch (type) {
case "audio":
return {
id,
type: "audio",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
volume: 1,
sourceType: "upload",
mediaId: `media-${id}`,
} satisfies AudioElement;
case "graphic":
return {
id,
type: "graphic",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
definitionId: `graphic-${id}`,
params: {},
transform: buildTransform(),
opacity: 1,
} satisfies GraphicElement;
case "text":
return {
id,
type: "text",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
content: id,
fontSize: 32,
fontFamily: "sans-serif",
color: "#ffffff",
background: {
enabled: false,
color: "#000000",
},
textAlign: "left",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
transform: buildTransform(),
opacity: 1,
} satisfies TextElement;
case "video":
return {
id,
type: "video",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
mediaId: `media-${id}`,
transform: buildTransform(),
opacity: 1,
} satisfies VideoElement;
}
throw new Error(`Unsupported test element type: ${type}`);
}
function buildTrack({
id,
type,
elements = [],
isMain = false,
}: {
type BuildTrackParams =
| {
id: string;
type: "audio";
elements?: AudioTrack["elements"];
isMain?: boolean;
}
| {
id: string;
type: "graphic";
elements?: GraphicTrack["elements"];
isMain?: boolean;
}
| {
id: string;
type: "text";
elements?: TextTrack["elements"];
isMain?: boolean;
}
| {
id: string;
type: "video";
elements?: VideoTrack["elements"];
isMain?: boolean;
};
function buildTrack(params: {
id: string;
type: TrackType;
elements?: TimelineElement[];
type: "audio";
elements?: AudioTrack["elements"];
isMain?: boolean;
}): TimelineTrack {
if (type === "video") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
isMain,
muted: false,
hidden: false,
};
}): AudioTrack;
function buildTrack(params: {
id: string;
type: "graphic";
elements?: GraphicTrack["elements"];
isMain?: boolean;
}): GraphicTrack;
function buildTrack(params: {
id: string;
type: "text";
elements?: TextTrack["elements"];
isMain?: boolean;
}): TextTrack;
function buildTrack(params: {
id: string;
type: "video";
elements?: VideoTrack["elements"];
isMain?: boolean;
}): VideoTrack;
function buildTrack(params: BuildTrackParams): TimelineTrack {
const { id, type, isMain = false } = params;
switch (type) {
case "audio":
return {
id,
type: "audio",
name: id,
elements: params.elements ?? [],
muted: false,
};
case "graphic":
return {
id,
type: "graphic",
name: id,
elements: params.elements ?? [],
hidden: false,
};
case "text":
return {
id,
type: "text",
name: id,
elements: params.elements ?? [],
hidden: false,
};
case "video":
return {
id,
type: "video",
name: id,
elements: params.elements ?? [],
isMain,
muted: false,
hidden: false,
};
}
if (type === "audio") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
muted: false,
};
}
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
hidden: false,
};
throw new Error(`Unsupported test track type: ${type}`);
}
function buildTimeSpan({
@ -124,7 +280,9 @@ describe("resolveTrackPlacement", () => {
buildTrack({
id: "text-1",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
],
}),
buildTrack({ id: "text-2", type: "text" }),
];
@ -256,7 +414,9 @@ describe("resolveTrackPlacement", () => {
buildTrack({
id: "text-middle",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
],
}),
buildTrack({ id: "text-source", type: "text" }),
];
@ -281,12 +441,16 @@ describe("resolveTrackPlacement", () => {
buildTrack({
id: "text-top",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
],
}),
buildTrack({
id: "text-source",
type: "text",
elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
],
}),
];

View File

@ -44,9 +44,7 @@ function buildExistingTrackResult({
trackId: track.id,
trackIndex,
trackType: track.type,
...(adjustedStartTime !== requestedStartTime
? { adjustedStartTime }
: {}),
...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}),
};
}
@ -114,6 +112,24 @@ function resolveAlwaysNewTrack({
});
}
function getInsertDirection({
hoverDirection,
verticalDragDirection,
}: {
hoverDirection: "above" | "below";
verticalDragDirection?: "up" | "down" | null;
}): "above" | "below" {
if (verticalDragDirection === "up") {
return "above";
}
if (verticalDragDirection === "down") {
return "below";
}
return hoverDirection;
}
export function resolveTrackPlacement({
tracks,
...placement
@ -127,7 +143,9 @@ export function resolveTrackPlacement({
const { timeSpans, strategy } = placement;
if (strategy.type === "explicit") {
const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId);
const trackIndex = tracks.findIndex(
(track) => track.id === strategy.trackId,
);
if (trackIndex < 0) {
return null;
}
@ -182,15 +200,16 @@ export function resolveTrackPlacement({
});
}
const insertDirection =
!isPreferredTrackCompatible && strategy.verticalDragDirection
? strategy.verticalDragDirection
: strategy.hoverDirection;
const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
tracks,
trackType,
preferredIndex: strategy.trackIndex,
direction: insertDirection,
direction: getInsertDirection({
hoverDirection: strategy.hoverDirection,
verticalDragDirection: !isPreferredTrackCompatible
? strategy.verticalDragDirection
: null,
}),
});
return buildNewTrackResult({
trackType,