summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/audio-separation
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/audio-separation
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/audio-separation')
-rw-r--r--apps/web/src/timeline/audio-separation/index.ts143
1 files changed, 143 insertions, 0 deletions
diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts
new file mode 100644
index 00000000..beb488de
--- /dev/null
+++ b/apps/web/src/timeline/audio-separation/index.ts
@@ -0,0 +1,143 @@
+import { cloneAnimations } from "@/animation";
+import type { ElementAnimations } from "@/animation/types";
+import type { MediaAsset } from "@/media/types";
+import { DEFAULTS } from "@/timeline/defaults";
+import type {
+ CreateUploadAudioElement,
+ TimelineElement,
+ AudioElement,
+ VideoElement,
+} from "../types";
+
+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,
+ });
+}