From f1043734438c8518a302f698d9ae6583a75f0c14 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 16 Aug 2026 06:16:56 +0200 Subject: feat: add transitions, track mixing, and voiceover controls --- .../src/components/editor/panels/assets/index.tsx | 12 +- .../editor/panels/assets/views/transitions.tsx | 258 +++++++++++++++++++++ .../components/track-audio-properties.tsx | 158 +++++++++++++ .../components/editor/panels/properties/index.tsx | 21 +- .../editor/panels/properties/registry.tsx | 38 ++- .../panels/properties/stores/properties-store.ts | 44 ++-- 6 files changed, 503 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/editor/panels/assets/views/transitions.tsx create mode 100644 apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx (limited to 'apps/web/src/components/editor') diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index c0204b12..6e134eda 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -1,10 +1,14 @@ "use client"; import { Separator } from "@/components/ui/separator"; -import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; +import { + type Tab, + useAssetsPanelStore, +} from "@/components/editor/panels/assets/assets-panel-store"; import { TabBar } from "./tabbar"; import { MediaView } from "./views/assets"; import { SettingsView } from "./views/settings"; +import { TransitionsView } from "./views/transitions"; import { SoundsView } from "@/sounds/components/assets-view"; import { StickersView } from "@/stickers/components/assets-view"; import { TextView } from "@/text/components/assets-view"; @@ -21,11 +25,7 @@ export function AssetsPanel() { stickers: FEATURES.stickers ? : null, effects: FEATURES.effects ? : null, lens: FEATURES.effects ? : null, - transitions: ( -
- Transitions view coming soon... -
- ), + transitions: , settings: , }; diff --git a/apps/web/src/components/editor/panels/assets/views/transitions.tsx b/apps/web/src/components/editor/panels/assets/views/transitions.tsx new file mode 100644 index 00000000..e0236a2f --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/transitions.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { + ArrowRightDoubleIcon, + MagicWand05Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { PanelView } from "./base-panel"; +import { Button } from "@/components/ui/button"; +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { TimelineElement, TimelineTrack } from "@/timeline"; +import { + type TransitionKind, + withVisualTransitionParams, +} from "@/rendering/transition"; + +const transitions: Array<{ + kind: TransitionKind; + name: string; + description: string; + inDuration: number; + outDuration: number; +}> = [ + { + kind: "fade", + name: "Soft fade", + description: "Clean dissolve for slides and stills.", + inDuration: 0.35, + outDuration: 0, + }, + { + kind: "push-left", + name: "Slide left", + description: "Deck-like horizontal slide motion.", + inDuration: 0.45, + outDuration: 0, + }, + { + kind: "push-right", + name: "Slide right", + description: "Reverse horizontal slide motion.", + inDuration: 0.45, + outDuration: 0, + }, + { + kind: "zoom", + name: "Zoom in", + description: "Subtle punch-in with a fade edge.", + inDuration: 0.4, + outDuration: 0, + }, + { + kind: "zoom-out", + name: "Zoom out", + description: "Settle inward from a wider opening.", + inDuration: 0.4, + outDuration: 0, + }, + { + kind: "spin", + name: "Spin dissolve", + description: "A restrained turn with a soft fade.", + inDuration: 0.45, + outDuration: 0, + }, +]; + +export function TransitionsView() { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + const scene = useEditor((currentEditor) => + currentEditor.scenes.getActiveSceneOrNull(), + ); + const currentTime = useEditor((currentEditor) => + currentEditor.playback.getCurrentTime(), + ); + const selectedVisualElements = editor.timeline + .getElementsWithTracks({ elements: selectedElements }) + .filter(({ element }) => isVisualElement(element)); + const nearestTarget = scene + ? findNearestTransitionTarget({ + tracks: [scene.tracks.main, ...scene.tracks.overlay], + currentTime, + }) + : null; + const targets = + selectedVisualElements.length > 0 + ? selectedVisualElements + : nearestTarget + ? [nearestTarget] + : []; + const selectedCount = selectedVisualElements.length; + + const applyTransition = ({ + kind, + inDuration, + outDuration, + }: { + kind: TransitionKind; + inDuration: number; + outDuration: number; + }) => { + if (targets.length === 0) { + return; + } + + editor.timeline.updateElements({ + updates: targets.map(({ track, element }) => ({ + trackId: track.id, + elementId: element.id, + patch: { + params: withVisualTransitionParams({ + params: element.params, + transitionKind: kind, + inDuration, + outDuration, + }), + }, + })), + }); + }; + + return ( + +
+
+ + Apply at the cut +
+

+ {selectedCount > 0 + ? `${selectedCount} selected clip${selectedCount === 1 ? "" : "s"}.` + : nearestTarget + ? `Ready for the cut into ${nearestTarget.element.name}.` + : "Add at least two image or video clips to one track."} +

+
+ +
+ {transitions.map((transition) => ( + + ))} +
+
+ ); +} + +function findNearestTransitionTarget({ + tracks, + currentTime, +}: { + tracks: TimelineTrack[]; + currentTime: number; +}): { track: TimelineTrack; element: TimelineElement } | null { + let nearest: { track: TimelineTrack; element: TimelineElement } | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const track of tracks) { + const elements = track.elements + .filter((element) => element.type === "video" || element.type === "image") + .slice() + .sort((left, right) => left.startTime - right.startTime); + for (let index = 1; index < elements.length; index += 1) { + const element = elements[index]; + const distance = Math.abs(element.startTime - currentTime); + if (distance < nearestDistance) { + nearest = { track, element }; + nearestDistance = distance; + } + } + } + return nearest; +} + +function TransitionCard({ + transition, + disabled, + onApply, +}: { + transition: (typeof transitions)[number]; + disabled: boolean; + onApply: (transition: (typeof transitions)[number]) => void; +}) { + const editor = useEditor(); + return ( + + ); +} + +function TransitionPreview({ kind }: { kind: TransitionKind }) { + return ( +
+
+
+
+ {kind === "fade" && ( +
+ )} + {kind === "push-left" && ( + + )} + {kind === "push-right" && ( + + )} + {kind === "zoom" && ( +
+ )} + {kind === "zoom-out" && ( +
+ )} + {kind === "spin" && ( +
+ )} +
+ ); +} 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 ( +
+
+
+ +
+
+
{track.name}
+
Track audio
+
+
+
+ + + +
+ { + sliderEditingRef.current = true; + const nextVolume = normalizeVolume(values[0]); + setVolume(nextVolume); + setVolumeDraft(formatVolume(nextVolume)); + }} + onValueCommit={(values) => { + sliderEditingRef.current = false; + commitVolume(values[0]); + }} + /> + { + 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} + /> +
+
+ + { + editor.timeline.toggleTrackMute({ trackId: track.id }); + }} + /> + +
+
+
+
+ ); +} 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 ( +
+ + + +
+ ); + } 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: , + content: ({ trackId }) => ( + + ), + }; +} function buildSpeedTab({ element, @@ -202,7 +229,9 @@ function buildGraphicTab({ id: "graphic", label: "Graphic", icon: , - content: ({ trackId }) => , + content: ({ 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; - setActiveTab: (args: { elementType: string; tabId: string }) => void; - isTransformScaleLocked: boolean; - setTransformScaleLocked: (args: { locked: boolean }) => void; -} - -export const usePropertiesStore = create()((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; + 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()((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 }), +})); -- cgit v1.3.1