diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views')
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/transitions.tsx | 258 |
1 files changed, 258 insertions, 0 deletions
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> + ); +} |
