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