From f1d5932c54178c4f8eac61d3d3e0e4a30101780f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 31 Mar 2026 23:23:36 +0200 Subject: feat: separate audio from elements --- .../editor/panels/properties/tabs/audio-tab.tsx | 98 ++++++++++++++-------- .../editor/panels/timeline/timeline-element.tsx | 35 ++++++++ .../editor/panels/timeline/timeline-toolbar.tsx | 63 ++++++++++++-- 3 files changed, 155 insertions(+), 41 deletions(-) (limited to 'apps/web/src/components/editor/panels') diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx index 4dbc1a17..5ac0fbb7 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx @@ -1,5 +1,7 @@ +import { Button } from "@/components/ui/button"; import { NumberField } from "@/components/ui/number-field"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation"; import { DEFAULTS } from "@/lib/timeline/defaults"; import { clamp, @@ -10,6 +12,7 @@ import { } from "@/utils/math"; import type { AudioElement, VideoElement } from "@/lib/timeline"; import { resolveNumberAtTime } from "@/lib/animation"; +import { useEditor } from "@/hooks/use-editor"; import { useElementPlayhead } from "../hooks/use-element-playhead"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; import { KeyframeToggle } from "../components/keyframe-toggle"; @@ -34,6 +37,7 @@ export function AudioTab({ element: AudioElement | VideoElement; trackId: string; }) { + const editor = useEditor(); const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ startTime: element.startTime, duration: element.duration, @@ -80,46 +84,68 @@ export function AudioTab({ rightValue: DEFAULTS.element.volume, }) : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; + const isSeparated = + element.type === "video" && isSourceAudioSeparated({ element }); return ( -
- - Audio - - - - + <> + {isSeparated && ( +
+

Audio has been separated.

+ +
+ )} +
+ + Audio + + + + } - isDefault={isDefault} - suffix="dB" - /> - - - -
+ > + } + value={volume.displayValue} + onFocus={volume.onFocus} + onChange={volume.onChange} + onBlur={volume.onBlur} + dragSensitivity="slow" + scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} + onScrub={volume.scrubTo} + onScrubEnd={volume.commitScrub} + onReset={() => + volume.commitValue({ + value: DEFAULTS.element.volume, + }) + } + isDefault={isDefault} + suffix="dB" + /> +
+
+
+
+ ); } diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index f196da44..bb30b0b0 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -39,6 +39,11 @@ import type { } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; +import { + canToggleSourceAudio, + getSourceAudioActionLabel, + isSourceAudioSeparated, +} from "@/lib/timeline/audio-separation"; import { getActionDefinition, type TAction, @@ -61,7 +66,9 @@ import { Search01Icon, Exchange01Icon, KeyframeIcon, + Link02Icon, MagicWand05Icon, + Unlink02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; @@ -280,6 +287,19 @@ export function TimelineElement({ }; const isMuted = canElementHaveAudio(element) && element.muted === true; + const canToggleCurrentSourceAudio = + selectedElements.length === 1 && + isCurrentElementSelected && + canToggleSourceAudio({ + element, + mediaAsset, + }); + const sourceAudioLabel = + element.type === "video" + ? getSourceAudioActionLabel({ element }) + : "Extract audio"; + const isElementSourceAudioSeparated = + element.type === "video" && isSourceAudioSeparated({ element }); return ( @@ -334,6 +354,21 @@ export function TimelineElement({ isMuted={isMuted} /> )} + {canToggleCurrentSourceAudio && ( + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("toggle-source-audio"); + }} + > + {sourceAudioLabel} + + )} {canElementBeHidden(element) && ( currentEditor.media.getAssets()); + const { selectedElements } = useElementSelection(); const isCurrentlyBookmarked = useEditor((e) => e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), ); + const selectedElement = + selectedElements.length === 1 + ? (editor.timeline.getElementsWithTracks({ + elements: selectedElements, + })[0] ?? null) + : null; + const selectedMediaAsset = (() => { + if (!selectedElement) { + return null; + } + + const { element } = selectedElement; + if (!hasMediaId(element)) { + return null; + } + + return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + })(); + const canToggleSelectedSourceAudio = + !!selectedElement && + canToggleSourceAudio({ + element: selectedElement.element, + mediaAsset: selectedMediaAsset, + }); + const sourceAudioLabel = + selectedElement?.element.type === "video" + ? getSourceAudioActionLabel({ + element: selectedElement.element, + }) + : "Extract audio"; + const isSelectedSourceAudioSeparated = + selectedElement?.element.type === "video" && + isSourceAudioSeparated({ + element: selectedElement.element, + }); const handleAction = ({ action, @@ -117,10 +163,16 @@ function ToolbarLeftSection() { /> } - tooltip="Separate audio (coming soon)" - disabled={true} - onClick={({ event: _event }) => {}} + icon={ + + } + tooltip={sourceAudioLabel} + disabled={!canToggleSelectedSourceAudio} + onClick={({ event }) => + handleAction({ action: "toggle-source-audio", event }) + } /> onClick({ event })} className={cn( "rounded-sm", -- cgit v1.3.1