summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels')
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx12
-rw-r--r--apps/web/src/components/editor/panels/assets/views/transitions.tsx258
-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
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 }),
+}));