diff options
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/components/editor-header.tsx | 77 | ||||
| -rw-r--r-- | apps/web/src/components/editor/snap-indicator.tsx | 29 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/index.tsx | 5 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-element.tsx | 12 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-playhead.tsx | 75 | ||||
| -rw-r--r-- | apps/web/src/components/onboarding.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/components/ui/command.tsx | 9 | ||||
| -rw-r--r-- | apps/web/src/components/ui/visually-hidden.tsx | 32 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keybindings.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-timeline-playhead.ts | 153 | ||||
| -rw-r--r-- | apps/web/src/lib/export-utils.ts | 294 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 103 |
12 files changed, 337 insertions, 468 deletions
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 788ef60d..837bd97a 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -11,79 +11,19 @@ import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; import { useState, useRef } from "react"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; -import { useMediaStore } from "@/stores/media-store"; -import { - VideoExporter, - downloadBlob, - type ExportOptions, -} from "@/lib/export-utils"; export function EditorHeader() { - const { getTotalDuration, tracks } = useTimelineStore(); + const { getTotalDuration } = useTimelineStore(); const { activeProject, renameProject } = useProjectStore(); - const { mediaItems } = useMediaStore(); const [isEditing, setIsEditing] = useState(false); const [newName, setNewName] = useState(activeProject?.name || ""); - const [isExporting, setIsExporting] = useState(false); const inputRef = useRef<HTMLInputElement>(null); - const handleExport = async () => { - if (!activeProject) { - toast.error("No active project to export"); - return; - } - - if (tracks.length === 0) { - toast.error("Timeline is empty - nothing to export"); - return; - } - - setIsExporting(true); - - try { - const exportOptions: ExportOptions = { - format: "mp4", - quality: "medium", - resolution: "1080p", - fps: activeProject.fps || 30, - }; - - const exporter = new VideoExporter((progress) => { - toast.loading( - `${progress.message} (${Math.round(progress.progress)}%)`, - { - id: "export-progress", - } - ); - }); - - const exportedBlob = await exporter.exportProject( - activeProject, - tracks, - mediaItems, - exportOptions - ); - - // Download the exported video - const filename = `${activeProject.name}.${exportOptions.format}`; - downloadBlob(exportedBlob, filename); - - toast.success("Video exported successfully!", { - id: "export-progress", - }); - - exporter.cleanup(); - } catch (error) { - console.error("Export failed:", error); - toast.error( - `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`, - { - id: "export-progress", - } - ); - } finally { - setIsExporting(false); - } + const handleExport = () => { + // TODO: Implement export functionality + // NOTE: This is already being worked on + console.log("Export project"); + window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank"); }; const handleNameClick = () => { @@ -169,12 +109,9 @@ export function EditorHeader() { variant="primary" className="h-7 text-xs" onClick={handleExport} - disabled={isExporting} > <Download className="h-4 w-4" /> - <span className="text-sm"> - {isExporting ? "Exporting..." : "Export"} - </span> + <span className="text-sm">Export</span> </Button> </nav> ); diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 302a52bb..053d119e 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -3,6 +3,7 @@ import { SnapPoint } from "@/hooks/use-timeline-snapping"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import type { TimelineTrack } from "@/types/timeline"; +import { useState, useEffect } from "react"; interface SnapIndicatorProps { snapPoint: SnapPoint | null; @@ -11,6 +12,7 @@ interface SnapIndicatorProps { tracks: TimelineTrack[]; timelineRef: React.RefObject<HTMLDivElement>; trackLabelsRef?: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; } export function SnapIndicator({ @@ -20,7 +22,29 @@ export function SnapIndicator({ tracks, timelineRef, trackLabelsRef, + tracksScrollRef, }: SnapIndicatorProps) { + const [scrollLeft, setScrollLeft] = useState(0); + + // Track scroll position to lock snap indicator to frame + useEffect(() => { + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + + if (!tracksViewport) return; + + const handleScroll = () => { + setScrollLeft(tracksViewport.scrollLeft); + }; + + // Set initial scroll position + setScrollLeft(tracksViewport.scrollLeft); + + tracksViewport.addEventListener("scroll", handleScroll); + return () => tracksViewport.removeEventListener("scroll", handleScroll); + }, [tracksScrollRef]); + if (!isVisible || !snapPoint) { return null; } @@ -34,9 +58,10 @@ export function SnapIndicator({ ? trackLabelsRef.current.offsetWidth : 0; - const leftPosition = - trackLabelsWidth + + // Calculate position locked to timeline content (accounting for scroll) + const timelinePosition = snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; return ( <div diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index bcaece89..bdb4433c 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -537,12 +537,13 @@ export function Timeline() { tracks={tracks} timelineRef={timelineRef} trackLabelsRef={trackLabelsRef} + tracksScrollRef={tracksScrollRef} isVisible={showSnapIndicator} /> {/* Timeline Header with Ruler */} <div className="flex bg-panel sticky top-0 z-10"> {/* Track Labels Header */} - <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2"> + <div className="w-48 flex-shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> {/* Empty space */} <span className="text-sm font-medium text-muted-foreground opacity-0"> . @@ -651,7 +652,7 @@ export function Timeline() { {tracks.length > 0 && ( <div ref={trackLabelsRef} - className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" + className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel" data-track-labels > <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index c66e9fcb..f581bbf0 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -157,16 +157,16 @@ export function TimelineElement({ if (!file) return; try { - const success = await replaceElementMedia(track.id, element.id, file); - if (success) { + const result = await replaceElementMedia(track.id, element.id, file); + if (result.success) { toast.success("Clip replaced successfully"); } else { - toast.error("Failed to replace clip"); + toast.error(result.error || "Failed to replace clip"); } } catch (error) { - toast.error("Failed to replace clip"); - console.log( - JSON.stringify({ error: "Failed to replace clip", details: error }) + console.error("Unexpected error replacing clip:", error); + toast.error( + `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` ); } }; diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index d0aea7fa..b33166c0 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -1,11 +1,8 @@ "use client"; -import { useRef } from "react"; +import { useRef, useState, useEffect } from "react"; import { TimelineTrack } from "@/types/timeline"; -import { - TIMELINE_CONSTANTS, - getTotalTracksHeight, -} from "@/constants/timeline-constants"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; interface TimelinePlayheadProps { @@ -39,6 +36,8 @@ export function TimelinePlayhead({ }: TimelinePlayheadProps) { const internalPlayheadRef = useRef<HTMLDivElement>(null); const playheadRef = externalPlayheadRef || internalPlayheadRef; + const [scrollLeft, setScrollLeft] = useState(0); + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ currentTime, duration, @@ -50,6 +49,25 @@ export function TimelinePlayhead({ playheadRef, }); + // Track scroll position to lock playhead to frame + useEffect(() => { + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + + if (!tracksViewport) return; + + const handleScroll = () => { + setScrollLeft(tracksViewport.scrollLeft); + }; + + // Set initial scroll position + setScrollLeft(tracksViewport.scrollLeft); + + tracksViewport.addEventListener("scroll", handleScroll); + return () => tracksViewport.removeEventListener("scroll", handleScroll); + }, [tracksScrollRef]); + // Use timeline container height minus a few pixels for breathing room const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; const totalHeight = timelineContainerHeight - 8; // 8px padding from edges @@ -59,9 +77,51 @@ export function TimelinePlayhead({ tracks.length > 0 && trackLabelsRef?.current ? trackLabelsRef.current.offsetWidth : 0; - const leftPosition = - trackLabelsWidth + + + // Calculate position locked to timeline content (accounting for scroll) + const timelinePosition = playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft; + + // Get the timeline content width and viewport width for right boundary + const timelineContentWidth = + duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const viewportWidth = tracksViewport?.clientWidth || 1000; + + // Constrain playhead to never appear outside the timeline area + const leftBoundary = trackLabelsWidth; + const rightBoundary = Math.min( + trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content + trackLabelsWidth + viewportWidth // Don't go beyond viewport + ); + + const leftPosition = Math.max( + leftBoundary, + Math.min(rightBoundary, rawLeftPosition) + ); + + // Debug logging when playhead might go outside + if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) { + console.log( + "PLAYHEAD VISUAL DEBUG:", + JSON.stringify({ + playheadPosition, + timelinePosition, + trackLabelsWidth, + scrollLeft, + rawLeftPosition, + constrainedLeftPosition: leftPosition, + leftBoundary, + rightBoundary, + timelineContentWidth, + viewportWidth, + zoomLevel, + }) + ); + } return ( <div @@ -72,7 +132,6 @@ export function TimelinePlayhead({ top: 0, height: `${totalHeight}px`, width: "2px", // Slightly wider for better click target - zIndex: 100, }} onMouseDown={handlePlayheadMouseDown} > diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx index c118e7c2..d72ac0f6 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/onboarding.tsx @@ -5,7 +5,6 @@ import { Button } from "./ui/button"; import { ArrowRightIcon } from "lucide-react"; import { useState, useEffect } from "react"; import ReactMarkdown from "react-markdown"; -import { VisuallyHidden } from "./ui/visually-hidden"; export function Onboarding() { const [step, setStep] = useState(0); @@ -56,7 +55,7 @@ export function Onboarding() { return ( <div className="space-y-5"> <div className="space-y-3"> - <Title title="⚠️ This is a super early beta!" /> + <Title title={getStepTitle()} /> <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." /> <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" /> </div> @@ -67,7 +66,7 @@ export function Onboarding() { return ( <div className="space-y-5"> <div className="space-y-3"> - <Title title="🦋 Have fun testing!" /> + <Title title={getStepTitle()} /> <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." /> </div> <NextButton onClick={handleClose}>Finish</NextButton> @@ -82,7 +81,7 @@ export function Onboarding() { <Dialog open={isOpen} onOpenChange={handleClose}> <DialogContent className="sm:max-w-[425px] !outline-none"> <DialogTitle> - <VisuallyHidden>{getStepTitle()}</VisuallyHidden> + <span className="sr-only">{getStepTitle()}</span> </DialogTitle> {renderStepContent()} </DialogContent> diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index b91cbd3d..11b27041 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -1,14 +1,12 @@ "use client"; import * as React from "react"; +import { DialogProps } from "@radix-ui/react-dialog"; import { Command as CommandPrimitive } from "cmdk"; import { Search } from "lucide-react"; import { cn } from "../../lib/utils"; -import { Dialog, DialogContent, DialogTitle } from "./dialog"; -import { VisuallyHidden } from "./visually-hidden"; - -type DialogProps = React.ComponentProps<typeof Dialog>; +import { Dialog, DialogContent } from "./dialog"; const Command = React.forwardRef< React.ElementRef<typeof CommandPrimitive>, @@ -29,9 +27,6 @@ const CommandDialog = ({ children, ...props }: DialogProps) => { return ( <Dialog {...props}> <DialogContent className="overflow-hidden p-0"> - <DialogTitle> - <VisuallyHidden>Command Menu</VisuallyHidden> - </DialogTitle> <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> {children} </Command> diff --git a/apps/web/src/components/ui/visually-hidden.tsx b/apps/web/src/components/ui/visually-hidden.tsx deleted file mode 100644 index 7d6fc435..00000000 --- a/apps/web/src/components/ui/visually-hidden.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import * as React from "react"; -import { cn } from "../../lib/utils"; - -/** - * VisuallyHidden component provides content that is accessible to screen readers - * but visually hidden from sighted users. This is useful for providing context - * or labels that are necessary for accessibility but would be redundant visually. - * - * Based on the Radix UI VisuallyHidden primitive pattern. - */ -const VisuallyHidden = React.forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef<"span"> ->(({ className, ...props }, ref) => ( - <span - ref={ref} - className={cn( - // Screen reader only styles - visually hidden but accessible - "absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0", - // Alternative approach using sr-only if preferred - // "sr-only", - className - )} - {...props} - /> -)); - -VisuallyHidden.displayName = "VisuallyHidden"; - -export { VisuallyHidden }; diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 10616900..7f6505b0 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -24,6 +24,15 @@ export function useKeybindingsListener() { const boundAction = keybindings[binding]; if (!boundAction) return; + const activeElement = document.activeElement; + const isTextInput = + activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + (activeElement as HTMLElement).isContentEditable); + + if (isTextInput) return; + ev.preventDefault(); // Handle actions with default arguments diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 008f61a4..ac59a8cd 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,6 +1,7 @@ import { snapTimeToFrame } from "@/constants/timeline-constants"; import { useProjectStore } from "@/stores/project-store"; -import { useState, useEffect, useCallback } from "react"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useState, useEffect, useCallback, useRef } from "react"; interface UseTimelinePlayheadProps { currentTime: number; @@ -31,6 +32,10 @@ export function useTimelinePlayhead({ const [isDraggingRuler, setIsDraggingRuler] = useState(false); const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + // Auto-scroll state during dragging + const autoScrollRef = useRef<number | null>(null); + const lastMouseXRef = useRef<number>(0); + const playheadPosition = isScrubbing && scrubTime !== null ? scrubTime : currentTime; @@ -70,21 +75,110 @@ export function useTimelinePlayhead({ const ruler = rulerRef.current; if (!ruler) return; const rect = ruler.getBoundingClientRect(); - const x = e.clientX - rect.left; + const rawX = e.clientX - rect.left; + + // Get the timeline content width based on duration and zoom + const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 + + // Constrain x to be within the timeline content bounds + const x = Math.max(0, Math.min(timelineContentWidth, rawX)); + const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); // Use frame snapping for playhead scrubbing const projectStore = useProjectStore.getState(); const projectFps = projectStore.activeProject?.fps || 30; const time = snapTimeToFrame(rawTime, projectFps); + + // Debug logging + if (rawX < 0 || x !== rawX) { + console.log( + "PLAYHEAD DEBUG:", + JSON.stringify({ + mouseX: e.clientX, + rulerLeft: rect.left, + rawX, + constrainedX: x, + timelineContentWidth, + rawTime, + finalTime: time, + duration, + zoomLevel, + playheadPx: time * 50 * zoomLevel, + }) + ); + } + setScrubTime(time); seek(time); // update video preview in real time + + // Store mouse position for auto-scrolling + lastMouseXRef.current = e.clientX; }, [duration, zoomLevel, seek, rulerRef] ); + // Auto-scroll function during dragging + const performAutoScroll = useCallback(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + + if (!rulerViewport || !tracksViewport || !isScrubbing) return; + + const viewportRect = rulerViewport.getBoundingClientRect(); + const mouseX = lastMouseXRef.current; + const mouseXRelative = mouseX - viewportRect.left; + + const edgeThreshold = 100; // pixels from edge to start scrolling + const maxScrollSpeed = 15; // max pixels per frame + const viewportWidth = rulerViewport.clientWidth; + + // Calculate timeline content boundaries + const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 + const scrollMax = Math.max(0, timelineContentWidth - viewportWidth); + + let scrollSpeed = 0; + + // Check if near left edge (and can scroll left) + if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) { + const edgeDistance = Math.max(0, mouseXRelative); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = -maxScrollSpeed * intensity; + } + // Check if near right edge (and can scroll right, and haven't reached timeline end) + else if ( + mouseXRelative > viewportWidth - edgeThreshold && + rulerViewport.scrollLeft < scrollMax + ) { + const edgeDistance = Math.max( + 0, + viewportWidth - edgeThreshold - mouseXRelative + ); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = maxScrollSpeed * intensity; + } + + if (scrollSpeed !== 0) { + const newScrollLeft = Math.max( + 0, + Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed) + ); + rulerViewport.scrollLeft = newScrollLeft; + tracksViewport.scrollLeft = newScrollLeft; + } + + if (isScrubbing) { + autoScrollRef.current = requestAnimationFrame(performAutoScroll); + } + }, [isScrubbing, rulerScrollRef, tracksScrollRef, duration, zoomLevel]); + // Mouse move/up event handlers useEffect(() => { if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => { handleScrub(e); // Mark that we've dragged if ruler drag is active @@ -92,11 +186,18 @@ export function useTimelinePlayhead({ setHasDraggedRuler(true); } }; + const onMouseUp = (e: MouseEvent) => { setIsScrubbing(false); if (scrubTime !== null) seek(scrubTime); // finalize seek setScrubTime(null); + // Stop auto-scrolling + if (autoScrollRef.current) { + cancelAnimationFrame(autoScrollRef.current); + autoScrollRef.current = null; + } + // Handle ruler click vs drag if (isDraggingRuler) { setIsDraggingRuler(false); @@ -107,11 +208,20 @@ export function useTimelinePlayhead({ setHasDraggedRuler(false); } }; + window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); + + // Start auto-scrolling + autoScrollRef.current = requestAnimationFrame(performAutoScroll); + return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); + if (autoScrollRef.current) { + cancelAnimationFrame(autoScrollRef.current); + autoScrollRef.current = null; + } }; }, [ isScrubbing, @@ -120,10 +230,16 @@ export function useTimelinePlayhead({ handleScrub, isDraggingRuler, hasDraggedRuler, + performAutoScroll, ]); - // --- Playhead auto-scroll effect --- + // --- Playhead auto-scroll effect (only during playback) --- useEffect(() => { + const { isPlaying } = usePlaybackStore.getState(); + + // Only auto-scroll during playback, not during manual interactions + if (!isPlaying || isScrubbing) return; + const rulerViewport = rulerScrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLElement; @@ -131,22 +247,33 @@ export function useTimelinePlayhead({ "[data-radix-scroll-area-viewport]" ) as HTMLElement; if (!rulerViewport || !tracksViewport) return; + const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 const viewportWidth = rulerViewport.clientWidth; const scrollMin = 0; const scrollMax = rulerViewport.scrollWidth - viewportWidth; - // Center the playhead if it's not visible (100px buffer) - const desiredScroll = Math.max( - scrollMin, - Math.min(scrollMax, playheadPx - viewportWidth / 2) - ); - if ( - playheadPx < rulerViewport.scrollLeft + 100 || - playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 - ) { + + // Only auto-scroll if playhead is completely out of view (no buffer) + const needsScroll = + playheadPx < rulerViewport.scrollLeft || + playheadPx > rulerViewport.scrollLeft + viewportWidth; + + if (needsScroll) { + // Center the playhead in the viewport + const desiredScroll = Math.max( + scrollMin, + Math.min(scrollMax, playheadPx - viewportWidth / 2) + ); rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; } - }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); + }, [ + playheadPosition, + duration, + zoomLevel, + rulerScrollRef, + tracksScrollRef, + isScrubbing, + ]); return { playheadPosition, diff --git a/apps/web/src/lib/export-utils.ts b/apps/web/src/lib/export-utils.ts deleted file mode 100644 index 8fae2bfb..00000000 --- a/apps/web/src/lib/export-utils.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { FFmpeg } from "@ffmpeg/ffmpeg"; -import { initFFmpeg } from "./ffmpeg-utils"; -import { TimelineTrack, TimelineElement } from "@/types/timeline"; -import { MediaItem } from "@/stores/media-store"; -import { TProject } from "@/types/project"; - -export type ExportOptions = { - format: "mp4" | "webm" | "mov"; - quality: "low" | "medium" | "high"; - resolution: "720p" | "1080p" | "4k"; - fps: number; -}; - -export type ExportProgress = { - phase: "preparing" | "processing" | "finalizing" | "complete"; - progress: number; // 0-100 - message: string; -}; - -export class VideoExporter { - private ffmpeg: FFmpeg | null = null; - private onProgress?: (progress: ExportProgress) => void; - private progressCallback?: (data: { progress: number }) => void; - - constructor(onProgress?: (progress: ExportProgress) => void) { - this.onProgress = onProgress; - } - - private updateProgress( - phase: ExportProgress["phase"], - progress: number, - message: string - ) { - if (this.onProgress) { - this.onProgress({ phase, progress, message }); - } - } - - async exportProject( - project: TProject, - tracks: TimelineTrack[], - mediaItems: MediaItem[], - options: ExportOptions - ): Promise<Blob> { - this.updateProgress("preparing", 0, "Initializing FFmpeg..."); - - this.ffmpeg = await initFFmpeg(); - - // Set up progress callback for FFmpeg - this.progressCallback = ({ progress }) => { - this.updateProgress("processing", progress * 100, "Rendering video..."); - }; - this.ffmpeg.on("progress", this.progressCallback); - - try { - this.updateProgress("preparing", 10, "Analyzing timeline..."); - - // Get timeline duration - const duration = this.calculateTimelineDuration(tracks); - if (duration === 0) { - throw new Error("Timeline is empty - nothing to export"); - } - - this.updateProgress("preparing", 20, "Preparing media files..."); - - // Process media tracks (video/image/audio) - const mediaTracks = tracks.filter( - (track) => track.type === "media" || track.type === "audio" - ); - - if (mediaTracks.length === 0) { - throw new Error("No media tracks found to export"); - } - - this.updateProgress("preparing", 40, "Setting up export parameters..."); - - // Get resolution settings - const { width, height } = this.getResolutionDimensions( - options.resolution - ); - - // Create a simple concatenation for now - // In a full implementation, this would handle overlays, transitions, etc. - const outputBlob = await this.renderSimpleTimeline( - mediaTracks, - mediaItems, - duration, - options, - width, - height - ); - - this.updateProgress("complete", 100, "Export complete!"); - return outputBlob; - } catch (error) { - console.error("Export failed:", error); - throw error; - } - } - - private calculateTimelineDuration(tracks: TimelineTrack[]): number { - let maxDuration = 0; - - for (const track of tracks) { - for (const element of track.elements) { - const elementEnd = - element.startTime + - element.duration - - element.trimStart - - element.trimEnd; - maxDuration = Math.max(maxDuration, elementEnd); - } - } - - return maxDuration; - } - - private getResolutionDimensions(resolution: string): { - width: number; - height: number; - } { - switch (resolution) { - case "720p": - return { width: 1280, height: 720 }; - case "1080p": - return { width: 1920, height: 1080 }; - case "4k": - return { width: 3840, height: 2160 }; - default: - return { width: 1920, height: 1080 }; - } - } - - private async renderSimpleTimeline( - tracks: TimelineTrack[], - mediaItems: MediaItem[], - duration: number, - options: ExportOptions, - width: number, - height: number - ): Promise<Blob> { - if (!this.ffmpeg) throw new Error("FFmpeg not initialized"); - - // For now, let's implement a simple case: export the first video/image element - // A full implementation would handle complex timeline rendering - - const firstMediaTrack = tracks.find((track) => track.type === "media"); - if (!firstMediaTrack || firstMediaTrack.elements.length === 0) { - throw new Error("No media elements found to export"); - } - - const firstElement = firstMediaTrack.elements[0]; - - // Type guard to ensure we have a media element - if (firstElement.type !== "media") { - throw new Error("First element is not a media element"); - } - - const mediaItem = mediaItems.find( - (item) => item.id === firstElement.mediaId - ); - - if (!mediaItem || !mediaItem.file) { - throw new Error("Media file not found for export"); - } - - this.updateProgress("processing", 10, "Processing media file..."); - - const inputName = `input.${this.getFileExtension(mediaItem.file.name)}`; - const outputName = `output.${options.format}`; - - // Write input file - await this.ffmpeg.writeFile( - inputName, - new Uint8Array(await mediaItem.file.arrayBuffer()) - ); - - // Build FFmpeg command based on media type and options - const ffmpegArgs = this.buildFFmpegCommand( - inputName, - outputName, - firstElement, - options, - width, - height, - duration - ); - - this.updateProgress("processing", 30, "Rendering video..."); - - // Execute FFmpeg command - await this.ffmpeg.exec(ffmpegArgs); - - this.updateProgress("finalizing", 90, "Finalizing export..."); - - // Read output file - const data = await this.ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: `video/${options.format}` }); - - // Cleanup - await this.ffmpeg.deleteFile(inputName); - await this.ffmpeg.deleteFile(outputName); - - return blob; - } - - private buildFFmpegCommand( - inputName: string, - outputName: string, - element: TimelineElement, - options: ExportOptions, - width: number, - height: number, - totalDuration: number - ): string[] { - const args = ["-i", inputName]; - - // Handle trimming - if (element.trimStart > 0) { - args.push("-ss", element.trimStart.toString()); - } - - // Set duration - const elementDuration = - element.duration - element.trimStart - element.trimEnd; - args.push("-t", Math.min(elementDuration, totalDuration).toString()); - - // Set resolution and scaling - args.push( - "-vf", - `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2` - ); - - // Set frame rate - args.push("-r", options.fps.toString()); - - // Set quality/bitrate based on options - const { videoBitrate, audioBitrate } = this.getQualitySettings( - options.quality - ); - args.push("-b:v", videoBitrate, "-b:a", audioBitrate); - - // Set codec based on format - if (options.format === "mp4") { - args.push("-c:v", "libx264", "-c:a", "aac"); - } else if (options.format === "webm") { - args.push("-c:v", "libvpx-vp9", "-c:a", "libopus"); - } else if (options.format === "mov") { - args.push("-c:v", "libx264", "-c:a", "aac"); - } - - args.push(outputName); - return args; - } - - private getQualitySettings(quality: string): { - videoBitrate: string; - audioBitrate: string; - } { - switch (quality) { - case "low": - return { videoBitrate: "1M", audioBitrate: "128k" }; - case "medium": - return { videoBitrate: "3M", audioBitrate: "192k" }; - case "high": - return { videoBitrate: "8M", audioBitrate: "320k" }; - default: - return { videoBitrate: "3M", audioBitrate: "192k" }; - } - } - - private getFileExtension(filename: string): string { - return filename.split(".").pop() || "mp4"; - } - - cleanup() { - if (this.ffmpeg && this.progressCallback) { - // Remove all progress listeners - this.ffmpeg.off("progress", this.progressCallback); - } - } -} - -// Utility function to download blob as file -export function downloadBlob(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 9e3f56d6..f3497be9 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -150,7 +150,7 @@ interface TimelineStore { trackId: string, elementId: string, newFile: File - ) => Promise<boolean>; + ) => Promise<{ success: boolean; error?: string }>; // Ripple editing functions updateElementStartTimeWithRipple: ( @@ -1077,18 +1077,33 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }, // Replace media for an element - replaceElementMedia: async (trackId, elementId, newFile) => { + replaceElementMedia: async ( + trackId: string, + elementId: string, + newFile: File + ): Promise<{ success: boolean; error?: string }> => { const { _tracks } = get(); const track = _tracks.find((t) => t.id === trackId); const element = track?.elements.find((c) => c.id === elementId); - if (!element || element.type !== "media") return false; + if (!element) { + return { success: false, error: "Timeline element not found" }; + } + + if (element.type !== "media") { + return { + success: false, + error: "Replace is only available for media clips", + }; + } try { const mediaStore = useMediaStore.getState(); const projectStore = useProjectStore.getState(); - if (!projectStore.activeProject) return false; + if (!projectStore.activeProject) { + return { success: false, error: "No active project found" }; + } // Import required media processing functions const { @@ -1099,7 +1114,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } = await import("./media-store"); const fileType = getFileType(newFile); - if (!fileType) return false; + if (!fileType) { + return { + success: false, + error: + "Unsupported file type. Please select a video, audio, or image file.", + }; + } // Process the new media file const mediaData: any = { @@ -1109,32 +1130,56 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { url: URL.createObjectURL(newFile), }; - // Get media-specific metadata - if (fileType === "image") { - const { width, height } = await getImageDimensions(newFile); - mediaData.width = width; - mediaData.height = height; - } else if (fileType === "video") { - const [duration, { thumbnailUrl, width, height }] = await Promise.all( - [getMediaDuration(newFile), generateVideoThumbnail(newFile)] - ); - mediaData.duration = duration; - mediaData.thumbnailUrl = thumbnailUrl; - mediaData.width = width; - mediaData.height = height; - } else if (fileType === "audio") { - mediaData.duration = await getMediaDuration(newFile); + try { + // Get media-specific metadata + if (fileType === "image") { + const { width, height } = await getImageDimensions(newFile); + mediaData.width = width; + mediaData.height = height; + } else if (fileType === "video") { + const [duration, { thumbnailUrl, width, height }] = + await Promise.all([ + getMediaDuration(newFile), + generateVideoThumbnail(newFile), + ]); + mediaData.duration = duration; + mediaData.thumbnailUrl = thumbnailUrl; + mediaData.width = width; + mediaData.height = height; + } else if (fileType === "audio") { + mediaData.duration = await getMediaDuration(newFile); + } + } catch (error) { + return { + success: false, + error: `Failed to process ${fileType} file: ${error instanceof Error ? error.message : "Unknown error"}`, + }; } // Add new media item to store - await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData); + try { + await mediaStore.addMediaItem( + projectStore.activeProject.id, + mediaData + ); + } catch (error) { + return { + success: false, + error: `Failed to add media to project: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } // Find the newly created media item const newMediaItem = mediaStore.mediaItems.find( (item) => item.file === newFile ); - if (!newMediaItem) return false; + if (!newMediaItem) { + return { + success: false, + error: "Failed to create media item in project. Please try again.", + }; + } get().pushHistory(); @@ -1160,15 +1205,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ) ); - return true; + return { success: true }; } catch (error) { - console.log( - JSON.stringify({ - error: "Failed to replace element media", - details: error, - }) - ); - return false; + console.error("Failed to replace element media:", error); + return { + success: false, + error: `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`, + }; } }, |
