summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/commands
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-31 23:24:33 +0200
committerMaze Winther <mazewinther@gmail.com>2026-03-31 23:24:33 +0200
commit608dc1a62fdb133e218f45796cf936bdfb27c7ae (patch)
tree605132265bab1f345b0edfee8bdadfcaf8e973db /apps/web/src/lib/commands
parentcc150c7cfb859dfc38372210ec5bcc8bd196bbc4 (diff)
parentf1d5932c54178c4f8eac61d3d3e0e4a30101780f (diff)
downloadopencut-608dc1a62fdb133e218f45796cf936bdfb27c7ae.tar.gz
Merge branch 'feature/separate-audio'
Diffstat (limited to 'apps/web/src/lib/commands')
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts143
2 files changed, 144 insertions, 0 deletions
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,
+ }),
+ });
+}