summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/placement/resolve.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/placement/resolve.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/placement/resolve.ts')
-rw-r--r--apps/web/src/timeline/placement/resolve.ts277
1 files changed, 277 insertions, 0 deletions
diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts
new file mode 100644
index 00000000..26275392
--- /dev/null
+++ b/apps/web/src/timeline/placement/resolve.ts
@@ -0,0 +1,277 @@
+import type { SceneTracks, TrackType, TimelineTrack } from "@/timeline";
+import {
+ getDefaultInsertIndexForTrack,
+ getHighestInsertIndexForTrack,
+ resolvePreferredNewTrackPlacement,
+} from "./insert-index";
+import { getTrackTypeForElementType } from "./compatibility";
+import { enforceMainTrackStart } from "./main-track";
+import { canPlaceTimeSpansOnTrack } from "./overlap";
+import type {
+ PlacementResult,
+ PlacementStrategy,
+ PlacementSubject,
+ PlacementTimeSpan,
+} from "./types";
+
+type ResolveTrackPlacementParams = PlacementSubject & {
+ tracks: SceneTracks;
+ timeSpans: PlacementTimeSpan[];
+ strategy: PlacementStrategy;
+};
+
+function buildExistingTrackResult({
+ track,
+ trackIndex,
+ tracks,
+ timeSpans,
+}: {
+ track: TimelineTrack;
+ trackIndex: number;
+ tracks: SceneTracks;
+ timeSpans: PlacementTimeSpan[];
+}): PlacementResult {
+ const firstSpan = timeSpans[0];
+ const requestedStartTime = firstSpan?.startTime ?? 0;
+ const adjustedStartTime = enforceMainTrackStart({
+ tracks,
+ targetTrackId: track.id,
+ requestedStartTime,
+ excludeElementId: firstSpan?.excludeElementId,
+ });
+ return {
+ kind: "existingTrack",
+ trackId: track.id,
+ trackIndex,
+ trackType: track.type,
+ ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}),
+ };
+}
+
+function buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition,
+}: {
+ trackType: TrackType;
+ insertIndex: number;
+ insertPosition: "above" | "below" | null;
+}): PlacementResult {
+ return {
+ kind: "newTrack",
+ trackType,
+ insertIndex,
+ insertPosition,
+ };
+}
+
+function findFirstAvailableTrackIndex({
+ tracks,
+ trackType,
+ timeSpans,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+ timeSpans: PlacementTimeSpan[];
+}): number {
+ return tracks.findIndex((track) => {
+ return (
+ track.type === trackType &&
+ canPlaceTimeSpansOnTrack({
+ track,
+ timeSpans,
+ })
+ );
+ });
+}
+
+function resolveAlwaysNewTrack({
+ tracks,
+ trackType,
+ position,
+}: {
+ tracks: SceneTracks;
+ trackType: TrackType;
+ position: "highest" | "default";
+}): PlacementResult {
+ const insertIndex =
+ position === "highest"
+ ? getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+ })
+ : getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+ });
+
+ return buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition: null,
+ });
+}
+
+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
+}: ResolveTrackPlacementParams): PlacementResult | null {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ const trackType =
+ "trackType" in placement
+ ? placement.trackType
+ : getTrackTypeForElementType({
+ elementType: placement.elementType,
+ });
+ const { timeSpans, strategy } = placement;
+
+ if (strategy.type === "explicit") {
+ const trackIndex = orderedTracks.findIndex(
+ (track) => track.id === strategy.trackId,
+ );
+ if (trackIndex < 0) {
+ return null;
+ }
+
+ const track = orderedTracks[trackIndex];
+ if (track.type !== trackType) {
+ return null;
+ }
+
+ return buildExistingTrackResult({
+ track,
+ trackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ if (strategy.type === "firstAvailable") {
+ const existingTrackIndex = findFirstAvailableTrackIndex({
+ tracks: orderedTracks,
+ trackType,
+ timeSpans,
+ });
+ if (existingTrackIndex >= 0) {
+ return buildExistingTrackResult({
+ track: orderedTracks[existingTrackIndex],
+ trackIndex: existingTrackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ return resolveAlwaysNewTrack({
+ tracks,
+ trackType,
+ position: "highest",
+ });
+ }
+
+ if (strategy.type === "preferIndex") {
+ const preferredTrack = orderedTracks[strategy.trackIndex];
+ const isPreferredTrackCompatible =
+ !!preferredTrack && preferredTrack.type === trackType;
+ const canUseExistingTrack =
+ !strategy.createNewTrackOnly &&
+ isPreferredTrackCompatible &&
+ canPlaceTimeSpansOnTrack({
+ track: preferredTrack,
+ timeSpans,
+ });
+ if (canUseExistingTrack) {
+ return buildExistingTrackResult({
+ track: preferredTrack,
+ trackIndex: strategy.trackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
+ tracks,
+ trackType,
+ preferredIndex: strategy.trackIndex,
+ direction: getInsertDirection({
+ hoverDirection: strategy.hoverDirection,
+ verticalDragDirection: !isPreferredTrackCompatible
+ ? strategy.verticalDragDirection
+ : null,
+ }),
+ });
+ return buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition,
+ });
+ }
+
+ if (strategy.type === "aboveSource") {
+ const aboveTrackIndex = strategy.sourceTrackIndex - 1;
+ const aboveTrack = orderedTracks[aboveTrackIndex];
+ if (
+ aboveTrack &&
+ aboveTrack.type === trackType &&
+ canPlaceTimeSpansOnTrack({
+ track: aboveTrack,
+ timeSpans,
+ })
+ ) {
+ return buildExistingTrackResult({
+ track: aboveTrack,
+ trackIndex: aboveTrackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
+ tracks: orderedTracks,
+ trackType,
+ timeSpans,
+ });
+ if (firstAvailableTrackIndex >= 0) {
+ return buildExistingTrackResult({
+ track: orderedTracks[firstAvailableTrackIndex],
+ trackIndex: firstAvailableTrackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ const insertIndex = getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+ });
+
+ return buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition: null,
+ });
+ }
+
+ return resolveAlwaysNewTrack({
+ tracks,
+ trackType,
+ position: strategy.position,
+ });
+}