Files
OpenCut/apps/web/src/timeline/audio-separation/index.ts
T
Maze WintherandCursor ab982b888e refactor: extract decoration model types
Move animation, effect, mask, and param value shapes into model decorations so element data no longer depends on decoration domains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 03:58:21 +02:00

139 lines
3.3 KiB
TypeScript

import { cloneAnimations } from "@/animation";
import type { ElementAnimations } from "@/model/decorations/animations";
import type { MediaAsset } from "@/media/types";
import { DEFAULTS } from "@/timeline/defaults";
import type { AudioElement, CreateUploadAudioElement, TimelineElement, VideoElement } from "@/model";
type MediaAudioState = Pick<MediaAsset, "hasAudio">;
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: TimelineElement,
mediaAsset: MediaAudioState | null | undefined,
): element is VideoElement {
return (
element.type === "video" &&
isSourceAudioEnabled({ element }) &&
!!mediaAsset &&
mediaAsset.hasAudio !== false
);
}
export function canRecoverSourceAudio(
element: TimelineElement,
): element is VideoElement {
return element.type === "video" && isSourceAudioSeparated({ element });
}
export function canToggleSourceAudio(
element: TimelineElement,
mediaAsset: MediaAudioState | null | undefined,
): element is VideoElement {
return (
canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset)
);
}
export function doesElementHaveEnabledAudio({
element,
mediaAsset,
}: {
element: AudioElement | VideoElement;
mediaAsset?: MediaAudioState | 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 volumeBinding = animations?.bindings.volume;
if (!volumeBinding) {
return undefined;
}
const subsetChannels = Object.fromEntries(
volumeBinding.components.flatMap((component) => {
const channel = animations?.channels[component.channelId];
return channel ? [[component.channelId, channel] as const] : [];
}),
);
if (Object.keys(subsetChannels).length === 0) {
return undefined;
}
return cloneAnimations({
animations: {
bindings: {
volume: volumeBinding,
},
channels: subsetChannels,
},
shouldRegenerateKeyframeIds: true,
});
}