summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
committercashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
commitf1043734438c8518a302f698d9ae6583a75f0c14 (patch)
treed3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/components/editor/panels/properties
parent37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff)
downloadopencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
-rw-r--r--apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx158
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx21
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx38
-rw-r--r--apps/web/src/components/editor/panels/properties/stores/properties-store.ts44
4 files changed, 239 insertions, 22 deletions
diff --git a/apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx b/apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx
new file mode 100644
index 00000000..4738d902
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { MusicNote03Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { NumberField } from "@/components/ui/number-field";
+import { Slider } from "@/components/ui/slider";
+import { Switch } from "@/components/ui/switch";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+} from "@/components/section";
+import { useEditor } from "@/editor/use-editor";
+import type { AudioTrack, VideoTrack } from "@/timeline";
+import { clampDb, getTrackVolume } from "@/timeline/audio-state";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import {
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ snapToStep,
+} from "@/utils/math";
+
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+function normalizeVolume(value: number): number {
+ return clampDb(snapToStep({ value, step: VOLUME_STEP }));
+}
+
+function formatVolume(value: number): string {
+ return formatNumberForDisplay({
+ value,
+ maxFractionDigits: VOLUME_FRACTION_DIGITS,
+ });
+}
+
+export function TrackAudioProperties({
+ track,
+}: {
+ track: AudioTrack | VideoTrack;
+}) {
+ const editor = useEditor();
+ const sourceVolume = getTrackVolume({ track });
+ const [volume, setVolume] = useState(sourceVolume);
+ const [volumeDraft, setVolumeDraft] = useState(formatVolume(sourceVolume));
+ const textEditingRef = useRef(false);
+ const sliderEditingRef = useRef(false);
+ const previousTrackIdRef = useRef(track.id);
+
+ useEffect(() => {
+ const trackChanged = previousTrackIdRef.current !== track.id;
+ if (trackChanged) {
+ previousTrackIdRef.current = track.id;
+ textEditingRef.current = false;
+ sliderEditingRef.current = false;
+ }
+ if (!trackChanged && (textEditingRef.current || sliderEditingRef.current)) {
+ return;
+ }
+ setVolume(sourceVolume);
+ setVolumeDraft(formatVolume(sourceVolume));
+ }, [sourceVolume, track.id]);
+
+ const commitVolume = (nextVolume: number) => {
+ const normalized = normalizeVolume(nextVolume);
+ setVolume(normalized);
+ setVolumeDraft(formatVolume(normalized));
+ if (normalized !== sourceVolume) {
+ editor.timeline.updateTrackVolume({
+ trackId: track.id,
+ volume: normalized,
+ });
+ }
+ };
+
+ const commitVolumeDraft = () => {
+ textEditingRef.current = false;
+ const parsed = parseFloat(volumeDraft);
+ if (Number.isNaN(parsed)) {
+ setVolume(sourceVolume);
+ setVolumeDraft(formatVolume(sourceVolume));
+ return;
+ }
+ commitVolume(parsed);
+ };
+
+ return (
+ <div className="flex min-h-full flex-col">
+ <div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
+ <div className="flex size-7 items-center justify-center rounded-md bg-accent">
+ <HugeiconsIcon icon={MusicNote03Icon} className="size-4" />
+ </div>
+ <div className="min-w-0">
+ <div className="truncate text-sm font-medium">{track.name}</div>
+ <div className="text-muted-foreground text-xs">Track audio</div>
+ </div>
+ </div>
+ <Section sectionKey={`track-audio:${track.id}`}>
+ <SectionContent className="pt-4">
+ <SectionFields>
+ <SectionField label="Volume">
+ <div className="flex items-center gap-3">
+ <Slider
+ className="min-w-0 flex-1"
+ aria-label={`${track.name} volume`}
+ value={[volume]}
+ min={VOLUME_DB_MIN}
+ max={VOLUME_DB_MAX}
+ step={VOLUME_STEP}
+ onValueChange={(values) => {
+ sliderEditingRef.current = true;
+ const nextVolume = normalizeVolume(values[0]);
+ setVolume(nextVolume);
+ setVolumeDraft(formatVolume(nextVolume));
+ }}
+ onValueCommit={(values) => {
+ sliderEditingRef.current = false;
+ commitVolume(values[0]);
+ }}
+ />
+ <NumberField
+ className="w-24 shrink-0"
+ aria-label={`${track.name} volume in decibels`}
+ value={volumeDraft}
+ min={VOLUME_DB_MIN}
+ max={VOLUME_DB_MAX}
+ step={VOLUME_STEP}
+ onFocus={() => {
+ textEditingRef.current = true;
+ }}
+ onChange={(event) => {
+ const nextDraft = event.currentTarget.value;
+ setVolumeDraft(nextDraft);
+ const parsed = parseFloat(nextDraft);
+ if (!Number.isNaN(parsed)) {
+ setVolume(normalizeVolume(parsed));
+ }
+ }}
+ onBlur={commitVolumeDraft}
+ />
+ </div>
+ </SectionField>
+ <SectionField label="Muted">
+ <Switch
+ checked={track.muted}
+ onCheckedChange={() => {
+ editor.timeline.toggleTrackMute({ trackId: track.id });
+ }}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index d946027d..f4ce9651 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -14,13 +14,32 @@ import { usePropertiesStore } from "./stores/properties-store";
import { getPropertiesConfig } from "./registry";
import { cn } from "@/utils/ui";
import { EmptyView } from "./empty-view";
+import { TrackAudioProperties } from "./components/track-audio-properties";
export function PropertiesPanel() {
const editor = useEditor();
useEditor((e) => e.scenes.getActiveSceneOrNull());
useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const { activeTabPerType, setActiveTab } = usePropertiesStore();
+ const { activeTabPerType, setActiveTab, selectedTrackId } =
+ usePropertiesStore();
+ const selectedTrack = selectedTrackId
+ ? editor.timeline.getTrackById({ trackId: selectedTrackId })
+ : null;
+
+ if (
+ selectedElements.length === 0 &&
+ selectedTrack &&
+ (selectedTrack.type === "audio" || selectedTrack.type === "video")
+ ) {
+ return (
+ <div className="panel bg-background flex h-full overflow-hidden rounded-sm border">
+ <ScrollArea className="flex-1 scrollbar-hidden">
+ <TrackAudioProperties key={selectedTrack.id} track={selectedTrack} />
+ </ScrollArea>
+ </div>
+ );
+ }
if (selectedElements.length === 0) {
return (
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
index 15f010f7..805098f8 100644
--- a/apps/web/src/components/editor/panels/properties/registry.tsx
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -23,7 +23,10 @@ import {
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
import { ElementParamsTab } from "./components/element-params-tab";
-import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
+import {
+ ClipEffectsTab,
+ StandaloneEffectTab,
+} from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab";
@@ -40,6 +43,11 @@ const TRANSFORM_PARAM_KEYS = [
const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
+const TRANSITION_PARAM_KEYS = [
+ "transition.kind",
+ "transition.inDuration",
+ "transition.outDuration",
+] as const;
const TEXT_PARAM_KEYS = [
"content",
"fontFamily",
@@ -135,6 +143,25 @@ function buildAudioTab({
),
};
}
+function buildTransitionTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transition",
+ label: "Transition",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={TRANSITION_PARAM_KEYS}
+ sectionKey="transition"
+ />
+ ),
+ };
+}
function buildSpeedTab({
element,
@@ -202,7 +229,9 @@ function buildGraphicTab({
id: "graphic",
label: "Graphic",
icon: <OcShapesIcon size={16} />,
- content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
+ content: ({ trackId }) => (
+ <GraphicTab element={element} trackId={trackId} />
+ ),
};
}
@@ -232,6 +261,7 @@ function getTextConfig({
buildTextTab({ element }),
buildTransformTab({ element }),
buildBlendingTab({ element }),
+ buildTransitionTab({ element }),
],
};
}
@@ -251,6 +281,7 @@ function getVideoConfig({
...(showAudioTab ? [buildAudioTab({ element })] : []),
buildSpeedTab({ element }),
buildBlendingTab({ element }),
+ buildTransitionTab({ element }),
buildMasksTab({ element }),
...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []),
],
@@ -267,6 +298,7 @@ function getImageConfig({
tabs: [
buildTransformTab({ element }),
buildBlendingTab({ element }),
+ buildTransitionTab({ element }),
buildMasksTab({ element }),
...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []),
],
@@ -283,6 +315,7 @@ function getStickerConfig({
tabs: [
buildTransformTab({ element }),
buildBlendingTab({ element }),
+ buildTransitionTab({ element }),
...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []),
],
};
@@ -299,6 +332,7 @@ function getGraphicConfig({
buildGraphicTab({ element }),
buildTransformTab({ element }),
buildBlendingTab({ element }),
+ buildTransitionTab({ element }),
buildMasksTab({ element }),
...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []),
],
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
index f6304349..3303dae2 100644
--- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
+++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
@@ -1,19 +1,25 @@
-import { create } from "zustand";
-
-interface PropertiesState {
- activeTabPerType: Record<string, string>;
- setActiveTab: (args: { elementType: string; tabId: string }) => void;
- isTransformScaleLocked: boolean;
- setTransformScaleLocked: (args: { locked: boolean }) => void;
-}
-
-export const usePropertiesStore = create<PropertiesState>()((set) => ({
- activeTabPerType: {},
- setActiveTab: ({ elementType, tabId }) =>
- set((state) => ({
- activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
- })),
- isTransformScaleLocked: false,
- setTransformScaleLocked: ({ locked }) =>
- set({ isTransformScaleLocked: locked }),
-}));
+import { create } from "zustand";
+
+interface PropertiesState {
+ activeTabPerType: Record<string, string>;
+ setActiveTab: (args: { elementType: string; tabId: string }) => void;
+ isTransformScaleLocked: boolean;
+ setTransformScaleLocked: (args: { locked: boolean }) => void;
+ selectedTrackId: string | null;
+ selectTrack: ({ trackId }: { trackId: string }) => void;
+ clearTrackSelection: () => void;
+}
+
+export const usePropertiesStore = create<PropertiesState>()((set) => ({
+ activeTabPerType: {},
+ setActiveTab: ({ elementType, tabId }) =>
+ set((state) => ({
+ activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
+ })),
+ isTransformScaleLocked: false,
+ setTransformScaleLocked: ({ locked }) =>
+ set({ isTransformScaleLocked: locked }),
+ selectedTrackId: null,
+ selectTrack: ({ trackId }) => set({ selectedTrackId: trackId }),
+ clearTrackSelection: () => set({ selectedTrackId: null }),
+}));