diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-09-03 00:02:07 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-09-03 00:02:07 +0200 |
| commit | 60debe2ce52a73dc554a0daa0a127fc836ca123a (patch) | |
| tree | 9c334f5de4211d0bb7893929ea63c86280679705 /apps/web/src/components/editor/timeline/index.tsx | |
| parent | 37d40148087fc7c3497fba31e82bc7c7496bde8d (diff) | |
| parent | 167e2b83dc072f5d80ab0bf0d3f786a993248c70 (diff) | |
| download | opencut-60debe2ce52a73dc554a0daa0a127fc836ca123a.tar.gz | |
Merge branch 'staging'
Diffstat (limited to 'apps/web/src/components/editor/timeline/index.tsx')
| -rw-r--r-- | apps/web/src/components/editor/timeline/index.tsx | 479 |
1 files changed, 49 insertions, 430 deletions
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index c994d707..4a59fd21 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -57,6 +57,9 @@ import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "../snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack, TrackType } from "@/types/timeline"; +import { TimelineCacheIndicator } from "./timeline-cache-indicator"; +import { TimelineMarker } from "./timeline-marker"; +import { useFrameCache } from "@/hooks/use-frame-cache"; import { getTrackHeight, getCumulativeHeightBefore, @@ -67,6 +70,7 @@ import { import { Slider } from "@/components/ui/slider"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { TimelineToolbar } from "./timeline-toolbar"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their elements. @@ -85,6 +89,7 @@ export function Timeline() { const { mediaFiles, addMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); const { currentTime, duration, seek, setDuration } = usePlaybackStore(); + const { getRenderStatus } = useFrameCache(); const [isDragOver, setIsDragOver] = useState(false); const { addElementToNewTrack } = useTimelineStore(); const dragCounterRef = useRef(0); @@ -658,7 +663,21 @@ export function Timeline() { onClick={handleTimelineContentClick} data-ruler-area > - <ScrollArea className="w-full" ref={rulerScrollRef}> + <ScrollArea + className="w-full" + ref={rulerScrollRef} + onScroll={(e) => { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + const tracksViewport = tracksScrollRef.current; + if (tracksViewport) { + tracksViewport.scrollLeft = ( + e.currentTarget as HTMLDivElement + ).scrollLeft; + } + isUpdatingRef.current = false; + }} + > <div ref={rulerRef} className="relative h-10 select-none cursor-default" @@ -667,6 +686,14 @@ export function Timeline() { }} onMouseDown={handleRulerMouseDown} > + <TimelineCacheIndicator + duration={duration} + zoomLevel={zoomLevel} + tracks={tracks} + mediaFiles={mediaFiles} + activeProject={activeProject} + getRenderStatus={getRenderStatus} + /> {/* Time markers */} {(() => { // Calculate appropriate time interval based on zoom level @@ -693,55 +720,13 @@ export function Timeline() { time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; return ( - <div + <TimelineMarker key={i} - className={`absolute top-0 h-4 ${ - isMainMarker - ? "border-l border-muted-foreground/40" - : "border-l border-muted-foreground/20" - }`} - style={{ - left: `${ - time * - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * - zoomLevel - }px`, - }} - > - <span - className={`absolute top-1 left-1 text-[0.6rem] ${ - isMainMarker - ? "text-muted-foreground font-medium" - : "text-muted-foreground/70" - }`} - > - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes - .toString() - .padStart(2, "0")}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (minutes > 0) { - return `${minutes}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (interval >= 1) { - return `${Math.floor(secs)}s`; - } - return `${secs.toFixed(1)}s`; - }; - return formatTime(time); - })()} - </span> - </div> + time={time} + zoomLevel={zoomLevel} + interval={interval} + isMainMarker={isMainMarker} + /> ); }).filter(Boolean); })()} @@ -836,7 +821,21 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - <ScrollArea className="w-full h-full" ref={tracksScrollRef}> + <ScrollArea + className="w-full h-full" + ref={tracksScrollRef} + onScroll={(e) => { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + const rulerViewport = rulerScrollRef.current; + if (rulerViewport) { + rulerViewport.scrollLeft = ( + e.currentTarget as HTMLDivElement + ).scrollLeft; + } + isUpdatingRef.current = false; + }} + > <div className="relative flex-1" style={{ @@ -924,383 +923,3 @@ function TrackIcon({ track }: { track: TimelineTrack }) { </> ); } - -function TimelineToolbar({ - zoomLevel, - setZoomLevel, -}: { - zoomLevel: number; - setZoomLevel: (zoom: number) => void; -}) { - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - selectedElements, - clearSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - - const handleSplitSelected = () => { - if (selectedElements.length === 0) return; - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) return; - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - - const handleZoomIn = () => { - setZoomLevel(Math.min(4, zoomLevel + 0.25)); - }; - - const handleZoomOut = () => { - setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); - }; - - const handleZoomSliderChange = (values: number[]) => { - setZoomLevel(values[0]); - }; - - const handleToggleBookmark = async () => { - await toggleBookmark(currentTime); - }; - - const currentBookmarked = isBookmarked(currentTime); - return ( - <div className="border-b flex items-center justify-between px-2 py-1"> - <div className="flex items-center gap-1 w-full"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={toggle} - className="mr-2" - > - {isPlaying ? ( - <Pause className="h-4 w-4" /> - ) : ( - <Play className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - {isPlaying ? "Pause (Space)" : "Play (Space)"} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={() => seek(0)} - className="mr-2" - > - <SkipBack className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Return to Start (Home / Enter)</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Time Display */} - <div className="flex flex-row items-center justify-center px-2"> - <EditableTimecode - time={currentTime} - duration={duration} - format="HH:MM:SS:FF" - fps={activeProject?.fps ?? DEFAULT_FPS} - onTimeChange={seek} - className="text-center" - /> - <div className="text-xs text-muted-foreground font-mono px-2"> - / - </div> - <div className="text-xs text-muted-foreground font-mono text-center"> - {formatTimeCode(duration, "HH:MM:SS:FF")} - </div> - </div> - {tracks.length === 0 && ( - <> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={() => { - const trackId = addTrack("media"); - addElementToTrack(trackId, { - type: "media", - mediaId: "test", - name: "Test Clip", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); - }} - className="text-xs" - > - Add Test Clip - </Button> - </TooltipTrigger> - <TooltipContent>Add a test clip to try playback</TooltipContent> - </Tooltip> - </> - )} - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSplitSelected}> - <Scissors className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split element (Ctrl+S)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepLeft} - > - <ArrowLeftToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepRight} - > - <ArrowRightToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSeparateAudio}> - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDuplicateSelected} - > - <Copy className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleFreezeSelected}> - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Freeze frame (F)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleDeleteSelected}> - <Trash2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Delete element (Delete)</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleToggleBookmark}> - <Bookmark - className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {currentBookmarked ? "Remove bookmark" : "Add bookmark"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleSnapping}> - {snappingEnabled ? ( - <Magnet className="h-4 w-4 text-primary" /> - ) : ( - <Magnet className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent>Auto snapping</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleRippleEditing}> - <Link - className={`h-4 w-4 ${ - rippleEditingEnabled ? "text-primary" : "" - }`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - - <div className="h-6 w-px bg-border mx-1" /> - <div className="flex items-center gap-1"> - <Button variant="text" size="icon" onClick={handleZoomOut}> - <ZoomOut className="h-4 w-4" /> - </Button> - <Slider - className="w-24" - value={[zoomLevel]} - onValueChange={handleZoomSliderChange} - min={0.25} - max={4} - step={0.25} - /> - <Button variant="text" size="icon" onClick={handleZoomIn}> - <ZoomIn className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - ); -} |
