diff options
Diffstat (limited to 'apps/web/src/lib')
| -rw-r--r-- | apps/web/src/lib/actions/definitions.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/timeline/element/index.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts | 143 | ||||
| -rw-r--r-- | apps/web/src/lib/media/audio.ts | 30 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts | 147 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/audio-separation/index.ts | 133 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/index.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts | 266 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/resolve.ts | 37 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/types.ts | 1 |
11 files changed, 694 insertions, 70 deletions
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts index e48126d0..3059a2e3 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -101,6 +101,10 @@ export const ACTIONS = { description: "Toggle ripple editing",
category: "editing",
},
+ "toggle-source-audio": {
+ description: "Extract or recover source audio",
+ category: "editing",
+ },
"select-all": {
description: "Select all elements",
category: "selection",
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index a7e513fe..5ce48d55 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -8,6 +8,7 @@ export { SplitElementsCommand } from "./split-elements"; export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
+export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts new file mode 100644 index 00000000..d0907c5b --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts @@ -0,0 +1,143 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { + buildSeparatedAudioElement, + canExtractSourceAudio, + canRecoverSourceAudio, +} from "@/lib/timeline/audio-separation"; +import { + applyPlacement, + resolveTrackPlacement, +} from "@/lib/timeline/placement"; +import { updateElementInTracks } from "@/lib/timeline/track-element-update"; +import type { TimelineTrack, VideoElement } from "@/lib/timeline/types"; +import { generateUUID } from "@/utils/id"; + +export class ToggleSourceAudioSeparationCommand extends Command { + private savedState: TimelineTrack[] | null = null; + + constructor( + private readonly params: { + trackId: string; + elementId: string; + }, + ) { + super(); + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const trackIndex = this.savedState.findIndex( + (track) => track.id === this.params.trackId, + ); + if (trackIndex < 0) { + return; + } + + const sourceTrack = this.savedState[trackIndex]; + const sourceElement = sourceTrack.elements.find( + (element) => element.id === this.params.elementId, + ); + if (!sourceElement) { + return; + } + + if (canRecoverSourceAudio({ element: sourceElement })) { + editor.timeline.updateTracks( + updateSourceAudioEnabled({ + tracks: this.savedState, + trackId: this.params.trackId, + elementId: this.params.elementId, + isSourceAudioEnabled: true, + }), + ); + return; + } + + const mediaAsset = editor + .media + .getAssets() + .find((asset) => + sourceElement.type === "video" ? asset.id === sourceElement.mediaId : false, + ); + if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) { + return; + } + if (sourceElement.duration <= 0) { + return; + } + + const separatedAudioElement = { + ...buildSeparatedAudioElement({ + sourceElement, + }), + id: generateUUID(), + }; + const placementResult = resolveTrackPlacement({ + tracks: this.savedState, + trackType: "audio", + timeSpans: [ + { + startTime: separatedAudioElement.startTime, + duration: separatedAudioElement.duration, + }, + ], + strategy: { type: "aboveSource", sourceTrackIndex: trackIndex }, + }); + if (!placementResult) { + return; + } + + const appliedPlacement = applyPlacement({ + tracks: this.savedState, + placementResult, + elements: [separatedAudioElement], + }); + if (!appliedPlacement) { + return; + } + + editor.timeline.updateTracks( + updateSourceAudioEnabled({ + tracks: appliedPlacement.updatedTracks, + trackId: this.params.trackId, + elementId: this.params.elementId, + isSourceAudioEnabled: false, + }), + ); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} + +function updateSourceAudioEnabled({ + tracks, + trackId, + elementId, + isSourceAudioEnabled, +}: { + tracks: TimelineTrack[]; + trackId: string; + elementId: string; + isSourceAudioEnabled: boolean; +}): TimelineTrack[] { + return updateElementInTracks({ + tracks, + trackId, + elementId, + elementPredicate: (element): element is VideoElement => element.type === "video", + update: (element) => ({ + ...element, + isSourceAudioEnabled, + }), + }); +} diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts index 41d3b38e..0afc42fb 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -12,7 +12,10 @@ import { hasAnimatedVolume, resolveEffectiveAudioGain, } from "@/lib/timeline/audio-state"; -import { canElementHaveAudio } from "@/lib/timeline/element-utils"; +import { + doesElementHaveEnabledAudio, +} from "@/lib/timeline/audio-separation"; +import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils"; import { canTracktHaveAudio } from "@/lib/timeline"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime"; @@ -100,6 +103,11 @@ export async function collectAudioElements({ if (!canElementHaveAudio(element)) continue; if (element.duration <= 0) continue; + const mediaAsset = hasMediaId(element) + ? (mediaMap.get(element.mediaId) ?? null) + : null; + if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; + const isTrackMuted = canTracktHaveAudio(track) && track.muted; if (element.type === "audio") { @@ -132,7 +140,6 @@ export async function collectAudioElements({ } if (element.type === "video") { - const mediaAsset = mediaMap.get(element.mediaId); if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue; pendingElements.push( @@ -451,6 +458,10 @@ export async function collectAudioMixSources({ for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; if (element.muted === true) continue; + const mediaAsset = hasMediaId(element) + ? (mediaMap.get(element.mediaId) ?? null) + : null; + if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; const volume = resolveEffectiveAudioGain({ element, localTime: 0, @@ -473,10 +484,7 @@ export async function collectAudioMixSources({ } if (element.type === "video") { - const mediaAsset = mediaMap.get(element.mediaId); - if (!mediaAsset) continue; - - if (mediaSupportsAudio({ media: mediaAsset })) { + if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) { audioMixSources.push( collectMediaAudioSource({ element, mediaAsset, volume }), ); @@ -512,6 +520,11 @@ export async function collectAudioClips({ for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; + const mediaAsset = hasMediaId(element) + ? (mediaMap.get(element.mediaId) ?? null) + : null; + if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; + const isElementMuted = "muted" in element ? (element.muted ?? false) : false; const muted = isTrackMuted || isElementMuted; @@ -543,10 +556,7 @@ export async function collectAudioClips({ } if (element.type === "video") { - const mediaAsset = mediaMap.get(element.mediaId); - if (!mediaAsset) continue; - - if (mediaSupportsAudio({ media: mediaAsset })) { + if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) { clips.push( collectMediaAudioClip({ element, diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts new file mode 100644 index 00000000..173b6402 --- /dev/null +++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { + buildSeparatedAudioElement, + doesElementHaveEnabledAudio, + isSourceAudioEnabled, + isSourceAudioSeparated, +} from ".."; + +describe("audio separation", () => { + test("treats missing source audio state as enabled", () => { + const element = buildVideoElement({}); + + expect(isSourceAudioEnabled({ element })).toBe(true); + expect(isSourceAudioSeparated({ element })).toBe(false); + }); + + test("builds a detached audio element from the source clip audio state", () => { + const element = buildVideoElement({ + duration: 8, + startTime: 3, + trimStart: 1.5, + trimEnd: 0.5, + sourceDuration: 12, + volume: -6, + muted: true, + retime: { rate: 1.25, maintainPitch: true }, + animations: { + channels: { + volume: { + valueKind: "number", + keyframes: [ + { + id: "volume-keyframe", + time: 2, + value: -12, + interpolation: "linear", + }, + ], + }, + opacity: { + valueKind: "number", + keyframes: [ + { + id: "opacity-keyframe", + time: 1, + value: 0.5, + interpolation: "linear", + }, + ], + }, + }, + }, + }); + + const separatedAudioElement = buildSeparatedAudioElement({ + sourceElement: element, + }); + + expect(separatedAudioElement).toMatchObject({ + type: "audio", + sourceType: "upload", + mediaId: element.mediaId, + name: element.name, + duration: 8, + startTime: 3, + trimStart: 1.5, + trimEnd: 0.5, + sourceDuration: 12, + volume: -6, + muted: true, + retime: { rate: 1.25, maintainPitch: true }, + }); + expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ + "volume", + ]); + expect( + separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id, + ).not.toBe("volume-keyframe"); + }); + + test("skips source audio collection when the source clip is separated", () => { + const mediaAsset = { + id: "media-1", + type: "video", + name: "Clip", + size: 1, + lastModified: 1, + file: new File(["video"], "clip.mp4", { type: "video/mp4" }), + url: "blob:clip", + hasAudio: true, + }; + const videoElement = buildVideoElement({ + isSourceAudioEnabled: false, + }); + const audioElement = { + id: "audio-1", + type: "audio", + sourceType: "upload", + mediaId: "audio-media-1", + name: "Detached audio", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + volume: 0, + } as AudioElement; + + expect( + doesElementHaveEnabledAudio({ + element: videoElement, + mediaAsset, + }), + ).toBe(false); + expect( + doesElementHaveEnabledAudio({ + element: audioElement, + }), + ).toBe(true); + }); +}); + +function buildVideoElement( + overrides: Partial<VideoElement>, +): VideoElement { + return { + id: "video-1", + type: "video", + mediaId: "media-1", + name: "Clip", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + volume: 0, + muted: false, + isSourceAudioEnabled: true, + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + ...overrides, + }; +} diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts new file mode 100644 index 00000000..b92cb79b --- /dev/null +++ b/apps/web/src/lib/timeline/audio-separation/index.ts @@ -0,0 +1,133 @@ +import { cloneAnimations, getChannel } from "@/lib/animation"; +import type { ElementAnimations } from "@/lib/animation/types"; +import type { MediaAsset } from "@/lib/media/types"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import type { + CreateUploadAudioElement, + TimelineElement, + AudioElement, + VideoElement, +} from "../types"; + +export function isSourceAudioEnabled({ + element, +}: { + element: VideoElement; +}): boolean { + return element.isSourceAudioEnabled !== false; +} + +export function isSourceAudioSeparated({ + element, +}: { + element: VideoElement; +}): boolean { + return !isSourceAudioEnabled({ element }); +} + +export function canExtractSourceAudio({ + element, + mediaAsset, +}: { + element: TimelineElement; + mediaAsset: MediaAsset | null | undefined; +}): element is VideoElement { + return ( + element.type === "video" && + isSourceAudioEnabled({ element }) && + !!mediaAsset && + mediaAsset.hasAudio !== false + ); +} + +export function canRecoverSourceAudio({ + element, +}: { + element: TimelineElement; +}): element is VideoElement { + return element.type === "video" && isSourceAudioSeparated({ element }); +} + +export function canToggleSourceAudio({ + element, + mediaAsset, +}: { + element: TimelineElement; + mediaAsset: MediaAsset | null | undefined; +}): element is VideoElement { + return ( + canRecoverSourceAudio({ element }) || + canExtractSourceAudio({ element, mediaAsset }) + ); +} + +export function doesElementHaveEnabledAudio({ + element, + mediaAsset, +}: { + element: AudioElement | VideoElement; + mediaAsset?: MediaAsset | null; +}): boolean { + if (element.type === "audio") { + return true; + } + + return !!mediaAsset && mediaAsset.hasAudio !== false && isSourceAudioEnabled({ element }); +} + +export function buildSeparatedAudioElement({ + sourceElement, +}: { + sourceElement: VideoElement; +}): CreateUploadAudioElement { + return { + type: "audio", + sourceType: "upload", + mediaId: sourceElement.mediaId, + name: sourceElement.name, + duration: sourceElement.duration, + startTime: sourceElement.startTime, + trimStart: sourceElement.trimStart, + trimEnd: sourceElement.trimEnd, + sourceDuration: sourceElement.sourceDuration, + volume: sourceElement.volume ?? DEFAULTS.element.volume, + muted: sourceElement.muted ?? false, + retime: sourceElement.retime + ? { + rate: sourceElement.retime.rate, + maintainPitch: sourceElement.retime.maintainPitch, + } + : undefined, + animations: cloneVolumeAnimations({ + animations: sourceElement.animations, + }), + }; +} + +export function getSourceAudioActionLabel({ + element, +}: { + element: VideoElement; +}): "Extract audio" | "Recover audio" { + return isSourceAudioSeparated({ element }) ? "Recover audio" : "Extract audio"; +} + +function cloneVolumeAnimations({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementAnimations | undefined { + const volumeChannel = getChannel({ animations, propertyPath: "volume" }); + if (!volumeChannel) { + return undefined; + } + + return cloneAnimations({ + animations: { + channels: { + volume: volumeChannel, + }, + }, + shouldRegenerateKeyframeIds: true, + }); +} diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index ed7bb174..5820b96d 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -242,6 +242,7 @@ function buildVideoElement({ trimEnd: 0,
sourceDuration: duration,
muted: false,
+ isSourceAudioEnabled: true,
hidden: false,
transform: {
...DEFAULTS.element.transform,
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index d4337833..83a6a0f0 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -5,6 +5,7 @@ export * from "./drag"; export * from "./track-utils";
export * from "./track-element-update";
export * from "./element-utils";
+export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
export * from "./ripple-utils";
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 95424160..e067fb16 100644 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -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; - if (type === "audio") { - return { - id, - type, - name: id, - elements: elements as TimelineTrack["elements"], - muted: false, - }; + 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, + }; } - 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 }), + ], }), ]; diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts index 235c789a..35718250 100644 --- a/apps/web/src/lib/timeline/placement/resolve.ts +++ b/apps/web/src/lib/timeline/placement/resolve.ts @@ -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, diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts index 69d363c7..ebdc4c23 100644 --- a/apps/web/src/lib/timeline/types.ts +++ b/apps/web/src/lib/timeline/types.ts @@ -113,6 +113,7 @@ export interface VideoElement extends BaseTimelineElement { mediaId: string;
volume?: number;
muted?: boolean;
+ isSourceAudioEnabled?: boolean;
hidden?: boolean;
retime?: RetimeConfig;
transform: Transform;
|
