diff options
Diffstat (limited to 'apps/web/src/components/editor')
6 files changed, 503 insertions, 28 deletions
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 ? <StickersView /> : null, effects: FEATURES.effects ? <EffectsView category="effect" /> : null, lens: FEATURES.effects ? <EffectsView category="lens" /> : null, - transitions: ( - <div className="text-muted-foreground p-4"> - Transitions view coming soon... - </div> - ), + transitions: <TransitionsView />, settings: <SettingsView />, }; 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 ( + <PanelView title="Transitions" contentClassName="flex flex-col gap-3 pb-3"> + <div className="rounded-md border bg-accent/20 p-3"> + <div className="flex items-center gap-2 text-sm font-medium"> + <HugeiconsIcon icon={MagicWand05Icon} className="size-4" /> + Apply at the cut + </div> + <p className="text-muted-foreground mt-1 text-xs leading-relaxed"> + {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."} + </p> + </div> + + <div className="grid grid-cols-1 gap-2"> + {transitions.map((transition) => ( + <TransitionCard + key={transition.kind} + transition={transition} + disabled={targets.length === 0} + onApply={applyTransition} + /> + ))} + </div> + </PanelView> + ); +} + +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 ( + <Button + variant="outline" + className="group h-auto justify-start overflow-hidden p-0 text-left" + draggable + aria-disabled={disabled} + onClick={() => onApply(transition)} + onDragStart={(event) => + editor.timeline.dragSource.begin({ + dataTransfer: event.dataTransfer, + dragData: { + type: "transition", + id: transition.kind, + name: transition.name, + transitionKind: transition.kind, + inDuration: transition.inDuration, + outDuration: transition.outDuration, + }, + }) + } + onDragEnd={() => editor.timeline.dragSource.end()} + > + <div className="flex w-full items-center gap-3 p-2"> + <TransitionPreview kind={transition.kind} /> + <div className="min-w-0 flex-1"> + <div className="text-sm font-medium">{transition.name}</div> + <div className="text-muted-foreground text-xs leading-snug"> + {transition.description} + </div> + </div> + </div> + </Button> + ); +} + +function TransitionPreview({ kind }: { kind: TransitionKind }) { + return ( + <div className="relative h-14 w-20 shrink-0 overflow-hidden rounded-sm border bg-background"> + <div className="absolute inset-0 bg-linear-to-br from-accent via-background to-primary/20" /> + <div className="absolute left-2 top-2 h-8 w-8 rounded-sm bg-foreground/80 shadow-sm" /> + <div className="absolute bottom-2 right-2 h-8 w-8 rounded-sm bg-primary/80 shadow-sm" /> + {kind === "fade" && ( + <div className="absolute inset-0 bg-linear-to-r from-transparent via-background/20 to-background/70" /> + )} + {kind === "push-left" && ( + <HugeiconsIcon + icon={ArrowRightDoubleIcon} + className="absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 text-background drop-shadow" + /> + )} + {kind === "push-right" && ( + <HugeiconsIcon + icon={ArrowRightDoubleIcon} + className="absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rotate-180 text-background drop-shadow" + /> + )} + {kind === "zoom" && ( + <div className="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-primary/80 shadow-[0_0_24px_var(--primary)]" /> + )} + {kind === "zoom-out" && ( + <div className="absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full border border-primary/80 shadow-[0_0_18px_var(--primary)]" /> + )} + {kind === "spin" && ( + <div className="absolute left-1/2 top-1/2 size-9 -translate-x-1/2 -translate-y-1/2 rotate-12 rounded-sm border border-primary/80 shadow-[0_0_18px_var(--primary)]" /> + )} + </div> + ); +} 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 }), +})); |
