diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-07 04:41:27 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-07 04:41:27 +0200 |
| commit | cbd1b8212370c67b34a70dccd21bd82de72fead2 (patch) | |
| tree | d97cbee2aae22c22b59c59a50e9f8a6af0ae8ccf /apps/web/src/lib/timeline | |
| parent | 4f7d401a974047d37673ee8e3840dceaaddc295c (diff) | |
| download | opencut-cbd1b8212370c67b34a70dccd21bd82de72fead2.tar.gz | |
refactor: structured track layout per scene
Diffstat (limited to 'apps/web/src/lib/timeline')
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 10 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/index.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts | 238 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/apply.ts | 149 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/index.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/insert-index.ts | 33 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/main-track.ts | 56 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/resolve.ts | 43 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/track-factory.ts | 66 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/snap-utils.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-element-update.ts | 152 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/types.ts | 13 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/update-pipeline.ts | 46 |
13 files changed, 562 insertions, 270 deletions
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 6f9e9dbc..e56acd3c 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -13,6 +13,7 @@ import { type CreateLibraryAudioElement, type TextBackground, type TextElement, + type SceneTracks, type TimelineElement, type TimelineTrack, type AudioElement, @@ -382,12 +383,13 @@ export function getElementsAtTime({ tracks, time, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; time: number; }): { trackId: string; elementId: string }[] { const result: { trackId: string; elementId: string }[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - for (const track of tracks) { + for (const track of orderedTracks) { for (const element of track.elements) { const elementStart = element.startTime; const elementEnd = element.startTime + element.duration; @@ -404,10 +406,10 @@ export function getElementsAtTime({ export function getElementFontFamilies({ tracks, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; }): string[] { const families = new Set<string>(); - for (const track of tracks) { + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { for (const element of track.elements) { if (element.type === "text" && element.fontFamily) { families.add(element.fontFamily); diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 194cd84b..5ee079b1 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,4 +1,4 @@ -import type { TimelineTrack } from "./types";
+import type { SceneTracks } from "./types";
export * from "./types";
export * from "./drag";
@@ -13,11 +13,12 @@ export * from "./pixel-utils"; export function calculateTotalDuration({
tracks,
}: {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
}): number {
- if (tracks.length === 0) return 0;
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ if (orderedTracks.length === 0) return 0;
- const trackEndTimes = tracks.map((track) =>
+ const trackEndTimes = orderedTracks.map((track) =>
track.elements.reduce((maxEnd, element) => {
const elementEnd = element.startTime + element.duration;
return Math.max(maxEnd, elementEnd);
diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts index 5ddccd90..4d39f5d8 100644 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -4,6 +4,8 @@ import type { AudioTrack, GraphicElement, GraphicTrack, + OverlayTrack, + SceneTracks, TextElement, TextTrack, TimelineTrack, @@ -134,53 +136,45 @@ 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: "audio"; elements?: AudioTrack["elements"]; - isMain?: boolean; }): 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; + const { id, type } = params; switch (type) { case "audio": @@ -213,7 +207,6 @@ function buildTrack(params: BuildTrackParams): TimelineTrack { type: "video", name: id, elements: params.elements ?? [], - isMain, muted: false, hidden: false, }; @@ -234,9 +227,32 @@ function buildTimeSpan({ return { startTime, duration, excludeElementId }; } +function buildSceneTracks({ + overlay = [], + main, + audio = [], +}: { + overlay?: Array<OverlayTrack>; + main?: VideoTrack; + audio?: Array<AudioTrack>; +}): SceneTracks { + return { + overlay, + main: + main ?? + buildTrack({ + id: "video-main", + type: "video", + }), + audio, + }; +} + describe("resolveTrackPlacement", () => { test("explicit returns the requested compatible track", () => { - const tracks = [buildTrack({ id: "text-1", type: "text" })]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + }); expect( resolveTrackPlacement({ @@ -254,7 +270,9 @@ describe("resolveTrackPlacement", () => { }); test("explicit rejects missing and incompatible tracks", () => { - const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-1", type: "video" }), + }); expect( resolveTrackPlacement({ @@ -276,16 +294,18 @@ describe("resolveTrackPlacement", () => { }); test("firstAvailable picks the first compatible track without overlap", () => { - const tracks = [ - buildTrack({ - id: "text-1", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ id: "text-2", type: "text" }), - ]; + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-1", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-2", type: "text" }), + ], + }); expect( resolveTrackPlacement({ @@ -303,21 +323,22 @@ describe("resolveTrackPlacement", () => { }); test("firstAvailable creates a new track when all compatible tracks are full", () => { - const tracks = [ - buildTrack({ - id: "graphic-1", - type: "graphic", - elements: [ - buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "graphic-1", + type: "graphic", + elements: [ + buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), + ], + }), + ], + main: buildTrack({ id: "video-main", type: "video", - isMain: true, }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -335,7 +356,9 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex uses the preferred track when it fits", () => { - const tracks = [buildTrack({ id: "audio-1", type: "audio" })]; + const tracks = buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -349,18 +372,18 @@ describe("resolveTrackPlacement", () => { }, }), ).toEqual({ - kind: "existingTrack", - trackId: "audio-1", - trackIndex: 0, + kind: "newTrack", trackType: "audio", + insertIndex: 1, + insertPosition: "below", }); }); test("preferIndex creates a new overlay track above the main track", () => { - const tracks = [ - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -382,11 +405,11 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex keeps audio tracks below the main track", () => { - const tracks = [ - buildTrack({ id: "text-1", type: "text" }), - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -409,17 +432,19 @@ describe("resolveTrackPlacement", () => { }); test("aboveSource tries the track above source, then any compatible track", () => { - const tracks = [ - buildTrack({ id: "text-top", type: "text" }), - buildTrack({ - id: "text-middle", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ id: "text-source", type: "text" }), - ]; + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ id: "text-top", type: "text" }), + buildTrack({ + id: "text-middle", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-source", type: "text" }), + ], + }); expect( resolveTrackPlacement({ @@ -436,23 +461,25 @@ describe("resolveTrackPlacement", () => { }); }); - test("aboveSource creates a new track near the source when none fit", () => { - const tracks = [ - buildTrack({ - id: "text-top", - type: "text", - 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 }), - ], - }), - ]; + test("aboveSource creates a new overlay track in the overlay zone when none fit", () => { + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-top", + type: "text", + 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 }), + ], + }), + ], + }); expect( resolveTrackPlacement({ @@ -464,16 +491,16 @@ describe("resolveTrackPlacement", () => { ).toEqual({ kind: "newTrack", trackType: "text", - insertIndex: 1, + insertIndex: 0, insertPosition: null, }); }); test("alwaysNew honors highest and default insertion rules", () => { - const tracks = [ - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -505,16 +532,18 @@ describe("resolveTrackPlacement", () => { }); test("batch time spans reject tracks when any span overlaps", () => { - const tracks = [ - buildTrack({ - id: "audio-1", - type: "audio", - elements: [ - buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), - buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), - ], - }), - ]; + const tracks = buildSceneTracks({ + audio: [ + buildTrack({ + id: "audio-1", + type: "audio", + elements: [ + buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), + buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), + ], + }), + ], + }); expect( resolveTrackPlacement({ @@ -534,10 +563,10 @@ describe("resolveTrackPlacement", () => { }); }); - test("handles empty timelines, single tracks, and track-type derivation", () => { + test("handles main-only timelines, single tracks, and track-type derivation", () => { expect( resolveTrackPlacement({ - tracks: [], + tracks: buildSceneTracks({}), elementType: "video", timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })], strategy: { @@ -551,12 +580,14 @@ describe("resolveTrackPlacement", () => { kind: "newTrack", trackType: "video", insertIndex: 0, - insertPosition: null, + insertPosition: "above", }); expect( resolveTrackPlacement({ - tracks: [buildTrack({ id: "audio-1", type: "audio" })], + tracks: buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }), elementType: "audio", timeSpans: [], strategy: { type: "alwaysNew", position: "default" }, @@ -564,22 +595,21 @@ describe("resolveTrackPlacement", () => { ).toEqual({ kind: "newTrack", trackType: "audio", - insertIndex: 1, + insertIndex: 2, insertPosition: null, }); }); test("existingTrack on main video includes adjustedStartTime when start snaps", () => { - const tracks = [ - buildTrack({ + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video", - isMain: true, elements: [ buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }), ], }), - ]; + }); expect( resolveTrackPlacement({ @@ -598,11 +628,11 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex uses vertical drag direction when hovered track is incompatible", () => { - const tracks = [ - buildTrack({ id: "text-1", type: "text" }), - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts index 4ff5d50d..639b1b42 100644 --- a/apps/web/src/lib/timeline/placement/apply.ts +++ b/apps/web/src/lib/timeline/placement/apply.ts @@ -1,7 +1,18 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + OverlayTrack, + SceneTracks, + TextTrack, + TimelineElement, + TimelineTrack, + VideoTrack, +} from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { buildEmptyTrack } from "./track-factory"; import type { PlacementResult } from "./types"; +import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update"; export function applyPlacement({ tracks, @@ -9,37 +20,139 @@ export function applyPlacement({ elements, newTrackInsertIndexOverride, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; placementResult: PlacementResult; elements: TimelineElement[]; newTrackInsertIndexOverride?: number; -}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { +}): { updatedTracks: SceneTracks; targetTrackId: string } | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; if (placementResult.kind === "existingTrack") { - const targetTrack = tracks[placementResult.trackIndex]; + const targetTrack = orderedTracks[placementResult.trackIndex]; if (!targetTrack) { return null; } - const updatedTracks = tracks.map((track, trackIndex) => - trackIndex === placementResult.trackIndex - ? { - ...track, - elements: [...track.elements, ...elements], - } - : track, - ) as TimelineTrack[]; + const updatedTracks = updateTrackInSceneTracks({ + tracks, + trackId: targetTrack.id, + update: (track) => ({ + ...track, + elements: [...track.elements, ...elements], + }), + }); return { updatedTracks, targetTrackId: targetTrack.id }; } const newTrackId = generateUUID(); - const newTrack = { - ...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }), - elements, - } as TimelineTrack; const insertIndex = newTrackInsertIndexOverride ?? placementResult.insertIndex; - const updatedTracks = [...tracks]; - updatedTracks.splice(insertIndex, 0, newTrack); + const updatedTracks = + placementResult.trackType === "audio" + ? { + ...tracks, + audio: insertIntoAudioTracks({ + tracks, + insertIndex, + track: buildPlacedAudioTrack({ + id: newTrackId, + elements, + }), + }), + } + : { + ...tracks, + overlay: insertIntoOverlayTracks({ + tracks, + insertIndex, + track: buildPlacedOverlayTrack({ + id: newTrackId, + type: placementResult.trackType, + elements, + }), + }), + }; return { updatedTracks, targetTrackId: newTrackId }; } + +function insertIntoOverlayTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: OverlayTrack; +}): OverlayTrack[] { + const normalizedInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + const nextTracks = [...tracks.overlay]; + nextTracks.splice(normalizedInsertIndex, 0, track); + return nextTracks; +} + +function insertIntoAudioTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: AudioTrack; +}): AudioTrack[] { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + const nextTracks = [...tracks.audio]; + nextTracks.splice(audioInsertIndex, 0, track); + return nextTracks; +} + +function buildPlacedAudioTrack({ + id, + elements, +}: { + id: string; + elements: TimelineElement[]; +}): AudioTrack { + return { + ...buildEmptyTrack({ id, type: "audio" }), + elements: elements as AudioTrack["elements"], + }; +} + +function buildPlacedOverlayTrack({ + id, + type, + elements, +}: { + id: string; + type: Exclude<OverlayTrack["type"], "audio">; + elements: TimelineElement[]; +}): OverlayTrack { + switch (type) { + case "video": + return { + ...buildEmptyTrack({ id, type: "video" }), + elements: elements as VideoTrack["elements"], + }; + case "text": + return { + ...buildEmptyTrack({ id, type: "text" }), + elements: elements as TextTrack["elements"], + }; + case "graphic": + return { + ...buildEmptyTrack({ id, type: "graphic" }), + elements: elements as GraphicTrack["elements"], + }; + case "effect": + return { + ...buildEmptyTrack({ id, type: "effect" }), + elements: elements as EffectTrack["elements"], + }; + } +} diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts index 091acc33..530cb69f 100644 --- a/apps/web/src/lib/timeline/placement/index.ts +++ b/apps/web/src/lib/timeline/placement/index.ts @@ -1,13 +1,7 @@ export { applyPlacement } from "./apply"; export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; -export { - enforceMainTrackStart, - ensureMainTrack, - getEarliestMainTrackElement, - getMainTrack, - isMainTrack, -} from "./main-track"; +export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track"; export { resolveTrackPlacement } from "./resolve"; export { buildEmptyTrack } from "./track-factory"; export type { diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts index fecb893a..04fa5a4a 100644 --- a/apps/web/src/lib/timeline/placement/insert-index.ts +++ b/apps/web/src/lib/timeline/placement/insert-index.ts @@ -1,44 +1,32 @@ -import type { TrackType, TimelineTrack } from "@/lib/timeline"; -import { isMainTrack } from "./main-track"; +import type { SceneTracks, TrackType } from "@/lib/timeline"; export function getDefaultInsertIndexForTrack({ tracks, trackType, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; }): number { if (trackType === "audio") { - return tracks.length; + return tracks.overlay.length + 1 + tracks.audio.length; } if (trackType === "effect") { return 0; } - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - if (mainTrackIndex >= 0) { - return mainTrackIndex; - } - - const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio"); - if (firstAudioTrackIndex >= 0) { - return firstAudioTrackIndex; - } - - return tracks.length; + return tracks.overlay.length; } export function getHighestInsertIndexForTrack({ tracks, trackType, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; }): number { - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); if (trackType === "audio") { - return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; + return tracks.overlay.length + 1; } return 0; @@ -50,12 +38,13 @@ export function resolvePreferredNewTrackPlacement({ preferredIndex, direction, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; preferredIndex: number; direction: "above" | "below"; }): { insertIndex: number; insertPosition: "above" | "below" | null } { - if (tracks.length === 0) { + const trackCount = tracks.overlay.length + 1 + tracks.audio.length; + if (trackCount === 0) { return { insertIndex: 0, insertPosition: trackType === "audio" ? "below" : null, @@ -64,9 +53,9 @@ export function resolvePreferredNewTrackPlacement({ const safePreferredIndex = Math.min( Math.max(preferredIndex, 0), - tracks.length - 1, + trackCount - 1, ); - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + const mainTrackIndex = tracks.overlay.length; if (trackType === "audio") { if (safePreferredIndex <= mainTrackIndex) { diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts index 1783352c..7e206517 100644 --- a/apps/web/src/lib/timeline/placement/main-track.ts +++ b/apps/web/src/lib/timeline/placement/main-track.ts @@ -1,55 +1,14 @@ -import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; +import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline"; -const MAIN_TRACK_NAME = "Main Track"; - -export function isMainTrack(track: TimelineTrack): track is VideoTrack { - return track.type === "video" && track.isMain === true; -} - -export function getMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): VideoTrack | null { - return tracks.find((track) => isMainTrack(track)) ?? null; -} - -export function ensureMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack[] { - if (tracks.some((track) => isMainTrack(track))) { - return tracks; - } - - return [ - { - id: generateUUID(), - name: MAIN_TRACK_NAME, - type: "video", - elements: [], - muted: false, - isMain: true, - hidden: false, - }, - ...tracks, - ]; -} +export const MAIN_TRACK_NAME = "Main Track"; export function getEarliestMainTrackElement({ - tracks, + mainTrack, excludeElementId, }: { - tracks: TimelineTrack[]; + mainTrack: VideoTrack; excludeElementId?: string; }): TimelineElement | null { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack) { - return null; - } - const elements = mainTrack.elements.filter((element) => { return !excludeElementId || element.id !== excludeElementId; }); @@ -70,18 +29,17 @@ export function enforceMainTrackStart({ requestedStartTime, excludeElementId, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; targetTrackId: string; requestedStartTime: number; excludeElementId?: string; }): number { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack || mainTrack.id !== targetTrackId) { + if (tracks.main.id !== targetTrackId) { return requestedStartTime; } const earliestElement = getEarliestMainTrackElement({ - tracks, + mainTrack: tracks.main, excludeElementId, }); if (!earliestElement) { diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts index d0417d0a..67943912 100644 --- a/apps/web/src/lib/timeline/placement/resolve.ts +++ b/apps/web/src/lib/timeline/placement/resolve.ts @@ -1,4 +1,4 @@ -import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline"; import { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack, @@ -15,7 +15,7 @@ import type { } from "./types"; type ResolveTrackPlacementParams = PlacementSubject & { - tracks: TimelineTrack[]; + tracks: SceneTracks; timeSpans: PlacementTimeSpan[]; strategy: PlacementStrategy; }; @@ -28,7 +28,7 @@ function buildExistingTrackResult({ }: { track: TimelineTrack; trackIndex: number; - tracks: TimelineTrack[]; + tracks: SceneTracks; timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; @@ -90,7 +90,7 @@ function resolveAlwaysNewTrack({ trackType, position, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; position: "highest" | "default"; }): PlacementResult { @@ -134,6 +134,7 @@ export function resolveTrackPlacement({ tracks, ...placement }: ResolveTrackPlacementParams): PlacementResult | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const trackType = "trackType" in placement ? placement.trackType @@ -143,30 +144,35 @@ export function resolveTrackPlacement({ const { timeSpans, strategy } = placement; if (strategy.type === "explicit") { - const trackIndex = tracks.findIndex( + const trackIndex = orderedTracks.findIndex( (track) => track.id === strategy.trackId, ); if (trackIndex < 0) { return null; } - const track = tracks[trackIndex]; + const track = orderedTracks[trackIndex]; if (track.type !== trackType) { return null; } - return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans }); + return buildExistingTrackResult({ + track, + trackIndex, + tracks, + timeSpans, + }); } if (strategy.type === "firstAvailable") { const existingTrackIndex = findFirstAvailableTrackIndex({ - tracks, + tracks: orderedTracks, trackType, timeSpans, }); if (existingTrackIndex >= 0) { return buildExistingTrackResult({ - track: tracks[existingTrackIndex], + track: orderedTracks[existingTrackIndex], trackIndex: existingTrackIndex, tracks, timeSpans, @@ -181,7 +187,7 @@ export function resolveTrackPlacement({ } if (strategy.type === "preferIndex") { - const preferredTrack = tracks[strategy.trackIndex]; + const preferredTrack = orderedTracks[strategy.trackIndex]; const isPreferredTrackCompatible = !!preferredTrack && preferredTrack.type === trackType; const canUseExistingTrack = @@ -220,7 +226,7 @@ export function resolveTrackPlacement({ if (strategy.type === "aboveSource") { const aboveTrackIndex = strategy.sourceTrackIndex - 1; - const aboveTrack = tracks[aboveTrackIndex]; + const aboveTrack = orderedTracks[aboveTrackIndex]; if ( aboveTrack && aboveTrack.type === trackType && @@ -238,26 +244,23 @@ export function resolveTrackPlacement({ } const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ - tracks, + tracks: orderedTracks, trackType, timeSpans, }); if (firstAvailableTrackIndex >= 0) { return buildExistingTrackResult({ - track: tracks[firstAvailableTrackIndex], + track: orderedTracks[firstAvailableTrackIndex], trackIndex: firstAvailableTrackIndex, tracks, timeSpans, }); } - const insertIndex = - strategy.sourceTrackIndex >= 0 - ? strategy.sourceTrackIndex - : getHighestInsertIndexForTrack({ - tracks, - trackType, - }); + const insertIndex = getHighestInsertIndexForTrack({ + tracks, + trackType, + }); return buildNewTrackResult({ trackType, diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts index ee056261..b73c5155 100644 --- a/apps/web/src/lib/timeline/placement/track-factory.ts +++ b/apps/web/src/lib/timeline/placement/track-factory.ts @@ -1,5 +1,13 @@ import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks"; -import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + TextTrack, + TrackType, + TimelineTrack, + VideoTrack, +} from "@/lib/timeline"; export function buildEmptyTrack({ id, @@ -7,6 +15,61 @@ export function buildEmptyTrack({ name, }: { id: string; + type: "video"; + name?: string; +}): VideoTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "text"; + name?: string; +}): TextTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "audio"; + name?: string; +}): AudioTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "graphic"; + name?: string; +}): GraphicTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "effect"; + name?: string; +}): EffectTrack; + +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: TrackType; + name?: string; +}): TimelineTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; type: TrackType; name?: string; }): TimelineTrack { @@ -21,7 +84,6 @@ export function buildEmptyTrack({ elements: [], hidden: false, muted: false, - isMain: false, }; case "text": return { diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index 2e7bdcec..c1768deb 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,4 +1,4 @@ -import type { Bookmark, TimelineTrack } from "@/lib/timeline"; +import type { Bookmark, SceneTracks } from "@/lib/timeline"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; import { getElementKeyframes } from "@/lib/animation"; import { TICKS_PER_SECOND } from "@/lib/wasm"; @@ -29,7 +29,7 @@ export function findSnapPoints({ enableBookmarkSnapping = true, enableKeyframeSnapping = true, }: { - tracks: Array<TimelineTrack>; + tracks: SceneTracks; playheadTime: number; excludeElementId?: string; bookmarks?: Array<Bookmark>; @@ -40,8 +40,9 @@ export function findSnapPoints({ enableKeyframeSnapping?: boolean; }): SnapPoint[] { const snapPoints: SnapPoint[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - for (const track of tracks) { + for (const track of orderedTracks) { for (const element of track.elements) { if (element.id === excludeElementId) continue; @@ -140,7 +141,7 @@ export function snapElementEdge({ }: { targetTime: number; elementDuration: number; - tracks: Array<TimelineTrack>; + tracks: SceneTracks; playheadTime: number; zoomLevel: number; excludeElementId?: string; diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts index 19765923..f1c2028e 100644 --- a/apps/web/src/lib/timeline/track-element-update.ts +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -1,33 +1,147 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline";
-export function updateElementInTracks({
+export function findTrackInSceneTracks({
tracks,
trackId,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+}): TimelineTrack | null {
+ if (tracks.main.id === trackId) {
+ return tracks.main;
+ }
+
+ return (
+ tracks.overlay.find((track) => track.id === trackId) ??
+ tracks.audio.find((track) => track.id === trackId) ??
+ null
+ );
+}
+
+export function updateTrackInSceneTracks({
+ tracks,
+ trackId,
+ update,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
+}): SceneTracks {
+ if (tracks.main.id === trackId) {
+ return {
+ ...tracks,
+ main: update(tracks.main),
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
+ if (overlayTrackIndex >= 0) {
+ return {
+ ...tracks,
+ overlay: tracks.overlay.map((track, index) =>
+ index === overlayTrackIndex ? update(track) : track,
+ ),
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
+ if (audioTrackIndex >= 0) {
+ return {
+ ...tracks,
+ audio: tracks.audio.map((track, index) =>
+ index === audioTrackIndex ? update(track) : track,
+ ),
+ };
+ }
+
+ return tracks;
+}
+
+function updateElementInTrack<TTrack extends TimelineTrack>({
+ track,
elementId,
update,
elementPredicate,
}: {
- tracks: TimelineTrack[];
- trackId: string;
+ track: TTrack;
elementId: string;
update: (element: TimelineElement) => TimelineElement;
elementPredicate?: (element: TimelineElement) => boolean;
-}): TimelineTrack[] {
- return tracks.map((track) => {
- if (track.id !== trackId) {
- return track;
+}): TTrack {
+ const nextElements = track.elements.map((element) => {
+ if (element.id !== elementId) {
+ return element;
+ }
+ if (elementPredicate && !elementPredicate(element)) {
+ return element;
}
+ return update(element);
+ });
- const nextElements = track.elements.map((element) => {
- if (element.id !== elementId) {
- return element;
- }
- if (elementPredicate && !elementPredicate(element)) {
- return element;
- }
- return update(element);
- });
+ return {
+ ...track,
+ elements: nextElements,
+ } as TTrack;
+}
- return { ...track, elements: nextElements } as TimelineTrack;
- });
+export function updateElementInSceneTracks({
+ tracks,
+ trackId,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): SceneTracks {
+ if (tracks.main.id === trackId) {
+ return {
+ ...tracks,
+ main: updateElementInTrack({
+ track: tracks.main,
+ elementId,
+ update,
+ elementPredicate,
+ }),
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
+ if (overlayTrackIndex >= 0) {
+ return {
+ ...tracks,
+ overlay: tracks.overlay.map((track, index) =>
+ index === overlayTrackIndex
+ ? updateElementInTrack({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+ })
+ : track,
+ ),
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
+ if (audioTrackIndex >= 0) {
+ return {
+ ...tracks,
+ audio: tracks.audio.map((track, index) =>
+ index === audioTrackIndex
+ ? updateElementInTrack({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+ })
+ : track,
+ ),
+ };
+ }
+
+ return tracks;
}
diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts index ebdc4c23..1b2c7c9b 100644 --- a/apps/web/src/lib/timeline/types.ts +++ b/apps/web/src/lib/timeline/types.ts @@ -20,7 +20,7 @@ export interface TScene { id: string;
name: string;
isMain: boolean;
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
bookmarks: Bookmark[];
createdAt: Date;
updatedAt: Date;
@@ -36,7 +36,6 @@ interface BaseTrack { export interface VideoTrack extends BaseTrack {
type: "video";
elements: (VideoElement | ImageElement)[];
- isMain: boolean;
muted: boolean;
hidden: boolean;
}
@@ -72,6 +71,14 @@ export type TimelineTrack = | GraphicTrack
| EffectTrack;
+export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
+
+export interface SceneTracks {
+ overlay: OverlayTrack[];
+ main: VideoTrack;
+ audio: AudioTrack[];
+}
+
export interface RetimeConfig {
rate: number;
maintainPitch?: boolean;
@@ -286,7 +293,7 @@ export interface ComputeDropTargetParams { elementType: ElementType;
mouseX: number;
mouseY: number;
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
playheadTime: number;
isExternalDrop: boolean;
elementDuration: number;
diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts index 61d09a9d..2be73441 100644 --- a/apps/web/src/lib/timeline/update-pipeline.ts +++ b/apps/web/src/lib/timeline/update-pipeline.ts @@ -4,14 +4,13 @@ import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/lib/retime"; -import { enforceMainTrackStart } from "@/lib/timeline/placement"; -import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { RetimeConfig, SceneTracks, TimelineElement } from "@/lib/timeline"; import { isRetimableElement } from "@/lib/timeline"; type ElementUpdateField = keyof TimelineElement | string; export interface ElementUpdateContext { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackId: string; } @@ -94,17 +93,36 @@ const enforceRules: ElementUpdateRule[] = [ }, { triggers: ["startTime"], - apply: ({ element, context }) => ({ - element: { - ...element, - startTime: enforceMainTrackStart({ - tracks: context.tracks, - targetTrackId: context.trackId, - requestedStartTime: Math.max(0, element.startTime), - excludeElementId: element.id, - }), - }, - }), + apply: ({ element, context }) => { + const requestedStartTime = Math.max(0, element.startTime); + if (context.trackId !== context.tracks.main.id) { + return { + element: { + ...element, + startTime: requestedStartTime, + }, + }; + } + + const earliestElement = context.tracks.main.elements + .filter((candidate) => candidate.id !== element.id) + .reduce<TimelineElement | null>((earliest, candidate) => { + if (!earliest || candidate.startTime < earliest.startTime) { + return candidate; + } + return earliest; + }, null); + + return { + element: { + ...element, + startTime: + !earliestElement || requestedStartTime <= earliestElement.startTime + ? 0 + : requestedStartTime, + }, + }; + }, }, ]; |
