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 --- .../editor/panels/assets/views/transitions.tsx | 258 +++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 apps/web/src/components/editor/panels/assets/views/transitions.tsx (limited to 'apps/web/src/components/editor/panels/assets/views') 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" && ( +
+ )} +
+ ); +} -- cgit v1.3.1