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 { 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"; 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({ function buildElement({
id, id,
type, type,
@ -9,61 +55,171 @@ function buildElement({
duration, duration,
}: { }: {
id: string; id: string;
type: ElementType; type: TestElement["type"];
startTime: number; startTime: number;
duration: number; duration: number;
}): TimelineElement { }): TestElement {
return { switch (type) {
id, case "audio":
type, return {
name: id, id,
startTime, type: "audio",
duration, name: id,
trimStart: 0, startTime,
trimEnd: 0, duration,
} as TimelineElement; 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({ type BuildTrackParams =
id, | {
type, id: string;
elements = [], type: "audio";
isMain = false, 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; id: string;
type: TrackType; type: "audio";
elements?: TimelineElement[]; elements?: AudioTrack["elements"];
isMain?: boolean; isMain?: boolean;
}): TimelineTrack { }): AudioTrack;
if (type === "video") { function buildTrack(params: {
return { id: string;
id, type: "graphic";
type, elements?: GraphicTrack["elements"];
name: id, isMain?: boolean;
elements: elements as TimelineTrack["elements"], }): GraphicTrack;
isMain, function buildTrack(params: {
muted: false, id: string;
hidden: false, 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") { throw new Error(`Unsupported test track type: ${type}`);
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
muted: false,
};
}
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
hidden: false,
};
} }
function buildTimeSpan({ function buildTimeSpan({
@ -124,7 +280,9 @@ describe("resolveTrackPlacement", () => {
buildTrack({ buildTrack({
id: "text-1", id: "text-1",
type: "text", 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" }), buildTrack({ id: "text-2", type: "text" }),
]; ];
@ -256,7 +414,9 @@ describe("resolveTrackPlacement", () => {
buildTrack({ buildTrack({
id: "text-middle", id: "text-middle",
type: "text", 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" }), buildTrack({ id: "text-source", type: "text" }),
]; ];
@ -281,12 +441,16 @@ describe("resolveTrackPlacement", () => {
buildTrack({ buildTrack({
id: "text-top", id: "text-top",
type: "text", type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], elements: [
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
],
}), }),
buildTrack({ buildTrack({
id: "text-source", id: "text-source",
type: "text", 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, trackId: track.id,
trackIndex, trackIndex,
trackType: track.type, trackType: track.type,
...(adjustedStartTime !== requestedStartTime ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}),
? { 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({ export function resolveTrackPlacement({
tracks, tracks,
...placement ...placement
@ -127,7 +143,9 @@ export function resolveTrackPlacement({
const { timeSpans, strategy } = placement; const { timeSpans, strategy } = placement;
if (strategy.type === "explicit") { 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) { if (trackIndex < 0) {
return null; return null;
} }
@ -182,15 +200,16 @@ export function resolveTrackPlacement({
}); });
} }
const insertDirection =
!isPreferredTrackCompatible && strategy.verticalDragDirection
? strategy.verticalDragDirection
: strategy.hoverDirection;
const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({ const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
tracks, tracks,
trackType, trackType,
preferredIndex: strategy.trackIndex, preferredIndex: strategy.trackIndex,
direction: insertDirection, direction: getInsertDirection({
hoverDirection: strategy.hoverDirection,
verticalDragDirection: !isPreferredTrackCompatible
? strategy.verticalDragDirection
: null,
}),
}); });
return buildNewTrackResult({ return buildNewTrackResult({
trackType, trackType,