Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions app/components/timeline/TimelineTracks.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import React, { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Scrubber } from "./Scrubber";
import { TransitionOverlay } from "./TransitionOverlay";
import {
DEFAULT_TRACK_HEIGHT,
PIXELS_PER_SECOND,
type ScrubberState,
type MediaBinItem,
type ScrubberState,
type TimelineState,
type Transition,
type Transition
} from "./types";

interface TimelineTracksProps {
Expand Down Expand Up @@ -39,6 +37,9 @@ interface TimelineTracksProps {
pixelsPerSecond: number;
selectedScrubberId: string | null;
onSelectScrubber: (scrubberId: string | null) => void;
// Add zoom and pan handlers
onZoom?: (delta: number, centerX: number) => void;
onPan?: (deltaX: number) => void;
}

export const TimelineTracks: React.FC<TimelineTracksProps> = ({
Expand All @@ -59,6 +60,8 @@ export const TimelineTracks: React.FC<TimelineTracksProps> = ({
pixelsPerSecond,
selectedScrubberId,
onSelectScrubber,
onZoom,
onPan,
}) => {
const [scrollTop, setScrollTop] = useState(0);

Expand Down Expand Up @@ -91,6 +94,32 @@ export const TimelineTracks: React.FC<TimelineTracksProps> = ({
}
}, [selectedScrubberId, containerRef, onSelectScrubber]);

// Wheel event handler for zoom and pan
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();

const container = containerRef.current;
if (!container || (!onZoom && !onPan) || timeline.tracks.length === 0) return;

const containerBounds = container.getBoundingClientRect();
const centerX = e.clientX - containerBounds.left + container.scrollLeft;

// Vertical scroll (deltaY) for zoom
if (onZoom && Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
// Zoom factor - negative deltaY means scroll up (zoom in), positive means scroll down (zoom out)
// Use smaller zoom increments for smoother zooming
const zoomFactor = e.deltaY < 0 ? 1.05 : 0.95;
onZoom(zoomFactor, centerX);
}

// Horizontal scroll (deltaX) for panning
if (onPan && Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
// Scale down the pan amount for smoother panning
const panAmount = -e.deltaX * 0.5;
onPan(panAmount);
}
};

return (
<div className="flex flex-1 min-h-0">
{/* Track controls column - scrolls with tracks */}
Expand Down Expand Up @@ -130,6 +159,7 @@ export const TimelineTracks: React.FC<TimelineTracksProps> = ({
className={`relative flex-1 bg-timeline-background timeline-scrollbar ${timeline.tracks.length === 0 ? "overflow-hidden" : "overflow-auto"
}`}
onScroll={timeline.tracks.length > 0 ? onScroll : undefined}
onWheel={handleWheel}
>
{timeline.tracks.length === 0 ? (
/* Empty state - non-scrollable and centered */
Expand Down
64 changes: 55 additions & 9 deletions app/hooks/useTimeline.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import {
PIXELS_PER_SECOND,
MIN_ZOOM,
MAX_ZOOM,
DEFAULT_ZOOM,
type TimelineState,
type TrackState,
type ScrubberState,
FPS,
MAX_ZOOM,
MIN_ZOOM,
PIXELS_PER_SECOND,
type MediaBinItem,
type ScrubberState,
type TimelineDataItem,
type TimelineState,
type TrackState,
type Transition,
FPS,
} from "../components/timeline/types";
import { generateUUID } from "../utils/uuid";
import { toast } from "sonner";

export const useTimeline = () => {
const [timeline, setTimeline] = useState<TimelineState>({
Expand Down Expand Up @@ -113,6 +113,50 @@ export const useTimeline = () => {
}));
}, []);

// New zoom function that zooms to a specific center point
const handleZoomToPoint = useCallback((zoomFactor: number, centerX: number) => {
const currentZoom = zoomLevelRef.current;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, currentZoom * zoomFactor));
const zoomRatio = newZoom / currentZoom;

zoomLevelRef.current = newZoom;
setZoomLevel(newZoom);

setTimeline((currentTimeline) => ({
...currentTimeline,
tracks: currentTimeline.tracks.map((track) => ({
...track,
scrubbers: track.scrubbers.map((scrubber) => {
// Calculate new position relative to zoom center
const scrubberCenter = scrubber.left + scrubber.width / 2;
const distanceFromCenter = scrubberCenter - centerX;
const newDistanceFromCenter = distanceFromCenter * zoomRatio;
const newLeft = centerX + newDistanceFromCenter - (scrubber.width * zoomRatio) / 2;

return {
...scrubber,
left: newLeft,
width: scrubber.width * zoomRatio,
};
}),
})),
}));
}, []);

// Pan function for horizontal scrolling
const handlePan = useCallback((deltaX: number) => {
setTimeline((currentTimeline) => ({
...currentTimeline,
tracks: currentTimeline.tracks.map((track) => ({
...track,
scrubbers: track.scrubbers.map((scrubber) => ({
...scrubber,
left: scrubber.left + deltaX,
})),
})),
}));
}, []);

// TODO: remove this after testing
// useEffect(() => {
// console.log('timeline meoeoeo', JSON.stringify(timeline, null, 2))
Expand Down Expand Up @@ -1087,6 +1131,8 @@ export const useTimeline = () => {
handleZoomIn,
handleZoomOut,
handleZoomReset,
handleZoomToPoint,
handlePan,
// Transition management
handleAddTransitionToTrack,
handleDeleteTransition,
Expand Down
12 changes: 6 additions & 6 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";

import type { Route } from "./+types/root";
Expand Down
74 changes: 28 additions & 46 deletions app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,53 @@
import React, { useRef, useEffect, useCallback, useState } from "react";
import type { PlayerRef, CallbackListener } from "@remotion/player";
import type { CallbackListener, PlayerRef } from "@remotion/player";
import {
ChevronLeft,
Download,
Minus,
Moon,
Sun,
Play,
Pause,
Upload,
Download,
Settings,
Play,
Plus,
Minus,
ChevronLeft,
Scissors,
Settings,
Star,
Sun,
Upload,
} from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";

// Custom video controls
import { MuteButton, FullscreenButton } from "~/components/ui/video-controls";
import { useTheme } from "next-themes";
import { FullscreenButton, MuteButton } from "~/components/ui/video-controls";

// Components
import { toast } from "sonner";
import LeftPanel from "~/components/editor/LeftPanel";
import { VideoPlayer } from "~/video-compositions/VideoPlayer";
import { RenderStatus } from "~/components/timeline/RenderStatus";
import { TimelineRuler } from "~/components/timeline/TimelineRuler";
import { TimelineTracks } from "~/components/timeline/TimelineTracks";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { toast } from "sonner";
import { Separator } from "~/components/ui/separator";
import { Switch } from "~/components/ui/switch";
import { VideoPlayer } from "~/video-compositions/VideoPlayer";

// Hooks
import { useTimeline } from "~/hooks/useTimeline";
import { useMediaBin } from "~/hooks/useMediaBin";
import { useRuler } from "~/hooks/useRuler";
import { useRenderer } from "~/hooks/useRenderer";
import { useRuler } from "~/hooks/useRuler";
import { useTimeline } from "~/hooks/useTimeline";

// Types and constants
import { FPS, type Transition } from "~/components/timeline/types";
import { useNavigate } from "react-router";
import { ChatBox } from "~/components/chat/ChatBox";
import { FPS, type Transition } from "~/components/timeline/types";

interface Message {
id: string;
Expand Down Expand Up @@ -119,6 +119,8 @@ export default function TimelineEditor() {
handleZoomIn,
handleZoomOut,
handleZoomReset,
handleZoomToPoint,
handlePan,
// Transition management
handleAddTransitionToTrack,
handleDeleteTransition,
Expand Down Expand Up @@ -355,8 +357,10 @@ export default function TimelineEditor() {
if (player) {
if (player.isPlaying()) {
player.pause();
setIsPlaying(false);
} else {
player.play();
setIsPlaying(true);
}
}
}
Expand Down Expand Up @@ -402,32 +406,7 @@ export default function TimelineEditor() {
}
}, [isDraggingRuler, handleRulerMouseMove, handleRulerMouseUp]);

// Timeline wheel zoom functionality
useEffect(() => {
const timelineContainer = containerRef.current;
if (!timelineContainer) return;

const handleWheel = (e: WheelEvent) => {
// Only zoom if Ctrl or Cmd is held
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const scrollDirection = e.deltaY > 0 ? -1 : 1;

if (scrollDirection > 0) {
handleZoomIn();
} else {
handleZoomOut();
}
}
};

timelineContainer.addEventListener("wheel", handleWheel, {
passive: false,
});
return () => {
timelineContainer.removeEventListener("wheel", handleWheel);
};
}, [handleZoomIn, handleZoomOut]);

useEffect(() => {
setMounted(true)
Expand Down Expand Up @@ -678,6 +657,7 @@ export default function TimelineEditor() {
>
{Math.round(((durationInFrames || 0) / FPS) * 10) / 10}s
</Badge>
{/* <span className="text-xs text-muted-foreground">• Mouse wheel to zoom</span> */}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center">
Expand Down Expand Up @@ -771,6 +751,8 @@ export default function TimelineEditor() {
pixelsPerSecond={getPixelsPerSecond()}
selectedScrubberId={selectedScrubberId}
onSelectScrubber={setSelectedScrubberId}
onZoom={handleZoomToPoint}
onPan={handlePan}
/>
</div>
</ResizablePanel>
Expand Down
2 changes: 1 addition & 1 deletion app/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Set to true for production, false for development
const isProduction = true;
const isProduction = false;

export const getApiBaseUrl = (fastapi: boolean = false): string => {
if (!isProduction) {
Expand Down