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 | |
| parent | 37d40148087fc7c3497fba31e82bc7c7496bde8d (diff) | |
| parent | 167e2b83dc072f5d80ab0bf0d3f786a993248c70 (diff) | |
| download | opencut-60debe2ce52a73dc554a0daa0a127fc836ca123a.tar.gz | |
Merge branch 'staging'
Diffstat (limited to 'apps/web/src/components/editor')
15 files changed, 1440 insertions, 787 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index c9738e81..246e8b6f 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -16,7 +16,7 @@ import { DEFAULT_EXPORT_OPTIONS, } from "@/lib/export"; import { useProjectStore } from "@/stores/project-store"; -import { Download, X } from "lucide-react"; +import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; import { PropertyGroup } from "./properties-panel/property-item"; @@ -132,137 +132,186 @@ function ExportPopover({ }; return ( - <PopoverContent className="w-80 mr-4 flex flex-col gap-3"> + <PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background"> <> - <div className="flex items-center justify-between"> - <h3 className=" font-medium"> - {isExporting ? "Exporting project" : "Export project"} - </h3> - <Button variant="text" size="icon" onClick={handleClose}> - <X className="!size-5 text-foreground/85" /> - </Button> - </div> + {exportResult && !exportResult.success ? ( + <ExportError + error={exportResult.error || "Unknown error occurred"} + onRetry={handleExport} + /> + ) : ( + <> + <div className="flex items-center justify-between"> + <h3 className=" font-medium"> + {isExporting ? "Exporting project" : "Export project"} + </h3> + <Button variant="text" size="icon" onClick={handleClose}> + <X className="!size-5 text-foreground/85" /> + </Button> + </div> - <div className="flex flex-col gap-4"> - {!isExporting && ( - <> - <div className="flex flex-col gap-3"> - <PropertyGroup - title="Format" - titleClassName="text-sm" - defaultExpanded={false} - > - <RadioGroup - value={format} - onValueChange={(value) => setFormat(value as ExportFormat)} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="mp4" id="mp4" /> - <Label htmlFor="mp4"> - MP4 (H.264) - Better compatibility - </Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="webm" id="webm" /> - <Label htmlFor="webm"> - WebM (VP9) - Smaller file size - </Label> - </div> - </RadioGroup> - </PropertyGroup> + <div className="flex flex-col gap-4"> + {!isExporting && ( + <> + <div className="flex flex-col gap-3"> + <PropertyGroup + title="Format" + titleClassName="text-sm" + defaultExpanded={false} + > + <RadioGroup + value={format} + onValueChange={(value) => + setFormat(value as ExportFormat) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="mp4" id="mp4" /> + <Label htmlFor="mp4"> + MP4 (H.264) - Better compatibility + </Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="webm" id="webm" /> + <Label htmlFor="webm"> + WebM (VP9) - Smaller file size + </Label> + </div> + </RadioGroup> + </PropertyGroup> - <PropertyGroup - title="Quality" - titleClassName="text-sm" - defaultExpanded={false} - > - <RadioGroup - value={quality} - onValueChange={(value) => - setQuality(value as ExportQuality) - } - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="low" id="low" /> - <Label htmlFor="low">Low - Smallest file size</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="medium" id="medium" /> - <Label htmlFor="medium">Medium - Balanced</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="high" id="high" /> - <Label htmlFor="high">High - Recommended</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="very_high" id="very_high" /> - <Label htmlFor="very_high"> - Very High - Largest file size - </Label> - </div> - </RadioGroup> - </PropertyGroup> + <PropertyGroup + title="Quality" + titleClassName="text-sm" + defaultExpanded={false} + > + <RadioGroup + value={quality} + onValueChange={(value) => + setQuality(value as ExportQuality) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="low" id="low" /> + <Label htmlFor="low">Low - Smallest file size</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="medium" id="medium" /> + <Label htmlFor="medium">Medium - Balanced</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="high" id="high" /> + <Label htmlFor="high">High - Recommended</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="very_high" id="very_high" /> + <Label htmlFor="very_high"> + Very High - Largest file size + </Label> + </div> + </RadioGroup> + </PropertyGroup> - <PropertyGroup - title="Audio" - titleClassName="text-sm" - defaultExpanded={false} - > - <div className="flex items-center space-x-2"> - <Checkbox - id="include-audio" - checked={includeAudio} - onCheckedChange={(checked) => setIncludeAudio(!!checked)} - /> - <Label htmlFor="include-audio"> - Include audio in export - </Label> + <PropertyGroup + title="Audio" + titleClassName="text-sm" + defaultExpanded={false} + > + <div className="flex items-center space-x-2"> + <Checkbox + id="include-audio" + checked={includeAudio} + onCheckedChange={(checked) => + setIncludeAudio(!!checked) + } + /> + <Label htmlFor="include-audio"> + Include audio in export + </Label> + </div> + </PropertyGroup> </div> - </PropertyGroup> - </div> - <Button onClick={handleExport} className="w-full gap-2"> - <Download className="w-4 h-4" /> - Export - </Button> - </> - )} - - {isExporting && ( - <div className="space-y-4"> - <div className="flex flex-col"> - <div className="text-center flex items-center justify-between"> - <p className="text-sm text-muted-foreground mb-2"> - {Math.round(progress * 100)}% - </p> - <p className="text-sm text-muted-foreground mb-2">100%</p> - </div> - <Progress value={progress * 100} className="w-full" /> - </div> + <Button onClick={handleExport} className="w-full gap-2"> + <Download className="w-4 h-4" /> + Export + </Button> + </> + )} - <Button - variant="outline" - className="rounded-md w-full" - onClick={() => {}} - > - Cancel - </Button> - </div> - )} + {isExporting && ( + <div className="space-y-4"> + <div className="flex flex-col"> + <div className="text-center flex items-center justify-between"> + <p className="text-sm text-muted-foreground mb-2"> + {Math.round(progress * 100)}% + </p> + <p className="text-sm text-muted-foreground mb-2">100%</p> + </div> + <Progress value={progress * 100} className="w-full" /> + </div> - {exportResult && !exportResult.success && ( - <div className="text-center space-y-3"> - <div className="text-red-600 font-medium">Export failed</div> - <p className="text-sm text-muted-foreground"> - {exportResult.error || "Unknown error occurred"} - </p> - <Button variant="outline" onClick={() => setExportResult(null)}> - Try Again - </Button> + <Button + variant="outline" + className="rounded-md w-full" + onClick={() => {}} + > + Cancel + </Button> + </div> + )} </div> - )} - </div> + </> + )} </> </PopoverContent> ); } + +function ExportError({ + error, + onRetry, +}: { + error: string; + onRetry: () => void; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(error); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }; + + return ( + <div className="space-y-4"> + <div className="flex flex-col gap-1.5"> + <p className="text-sm font-medium text-red-400">Export failed</p> + <p className="text-xs text-muted-foreground"> + {error} + </p> + </div> + + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="flex-1 text-xs h-8" + onClick={handleCopy} + > + {copied ? <Check className="text-green-500" /> : <Copy />} + Copy + </Button> + <Button + variant="outline" + size="sm" + className="flex-1 text-xs h-8" + onClick={onRetry} + > + <RotateCcw /> + Retry + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 56437508..e2bfdff7 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -7,13 +7,45 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useEffect, useRef, useState } from "react"; export function TabBar() { const { activeTab, setActiveTab } = useMediaPanelStore(); + const scrollRef = useRef<HTMLDivElement>(null); + const [showTopFade, setShowTopFade] = useState(false); + const [showBottomFade, setShowBottomFade] = useState(false); + + const checkScrollPosition = () => { + const element = scrollRef.current; + if (!element) return; + + const { scrollTop, scrollHeight, clientHeight } = element; + setShowTopFade(scrollTop > 0); + setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); + }; + + useEffect(() => { + const element = scrollRef.current; + if (!element) return; + + checkScrollPosition(); + element.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(element); + + return () => { + element.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, []); return ( - <div className="flex"> - <div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4"> + <div className="flex relative"> + <div + ref={scrollRef} + className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4" + > {(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; return ( @@ -46,6 +78,22 @@ export function TabBar() { ); })} </div> + + <FadeOverlay direction="top" show={showTopFade} /> + <FadeOverlay direction="bottom" show={showBottomFade} /> </div> ); } + +function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) { + return ( + <div + className={cn( + "absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200", + direction === "top" && show + ? "top-0 bg-gradient-to-b from-panel to-transparent" + : "bottom-0 bg-gradient-to-t from-panel to-transparent" + )} + /> + ); +}
\ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx index 59403a8d..fbe5b418 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -188,7 +188,7 @@ export function Captions() { }; return ( - <BaseView ref={containerRef} className="flex flex-col justify-between"> + <BaseView ref={containerRef} className="flex flex-col justify-between h-full"> <PropertyGroup title="Language"> <LanguageSelect selectedCountry={selectedCountry} diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index df1ec54f..be54a9c4 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -16,15 +16,18 @@ import { } from "../../properties-panel/property-item"; import { FPS_PRESETS } from "@/constants/timeline-constants"; import { useProjectStore } from "@/stores/project-store"; +import type { BlurIntensity } from "@/types/project"; import { useEditorStore } from "@/stores/editor-store"; import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import Image from "next/image"; import { cn } from "@/lib/utils"; import { colors } from "@/data/colors/solid"; import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { PipetteIcon } from "lucide-react"; +import { PipetteIcon, PlusIcon } from "lucide-react"; import { useMemo, memo, useCallback } from "react"; import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; export function SettingsView() { return <ProjectSettingsTabs />; @@ -38,14 +41,40 @@ function ProjectSettingsTabs() { { value: "project-info", label: "Project info", - content: <ProjectInfoView />, + content: ( + <div className="p-5"> + <ProjectInfoView /> + </div> + ), }, { value: "background", label: "Background", - content: <BackgroundView />, + content: ( + <div className="flex flex-col justify-between h-full"> + <div className="flex-1 p-5"> + <BackgroundView /> + </div> + <div className="flex flex-col sticky -bottom-0 bg-panel/85 backdrop-blur-lg"> + <Separator /> + <Button className="w-fit h-auto p-5 py-4 !bg-transparent shadow-none text-muted-foreground hover:text-foreground/85 text-xs"> + Custom background + <PlusIcon /> + </Button> + </div> + + {/* Another UI, looks so beautiful i don't wanna remove it */} + {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0"> + <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full"> + <span className="text-sm">Custom</span> + <PlusIcon className="" /> + </Button> + </div> */} + </div> + ), }, ]} + className="flex flex-col justify-between h-full p-0" /> ); } @@ -177,9 +206,9 @@ const BackgroundPreviews = memo( }) => { return useMemo( () => - backgrounds.map((bg) => ( + backgrounds.map((bg, index) => ( <div - key={bg} + key={`${index}-${bg}`} className={cn( "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary", isColorBackground && @@ -215,7 +244,7 @@ BackgroundPreviews.displayName = "BackgroundPreviews"; function BackgroundView() { const { activeProject, updateBackgroundType } = useProjectStore(); - const blurLevels = useMemo( + const blurLevels = useMemo<Array<{ label: string; value: BlurIntensity }>>( () => [ { label: "Light", value: 4 }, { label: "Medium", value: 8 }, @@ -225,7 +254,7 @@ function BackgroundView() { ); const handleBlurSelect = useCallback( - async (blurIntensity: number) => { + async (blurIntensity: BlurIntensity) => { await updateBackgroundType("blur", { blurIntensity }); }, [updateBackgroundType] @@ -257,7 +286,7 @@ function BackgroundView() { ); return ( - <div className="flex flex-col gap-4"> + <div className="flex flex-col gap-4 h-full"> <PropertyGroup title="Blur" defaultExpanded={false}> <div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div> </PropertyGroup> @@ -277,7 +306,7 @@ function BackgroundView() { </div> </PropertyGroup> - <PropertyGroup title="Pattern Craft" defaultExpanded={false}> + <PropertyGroup title="Pattern craft" defaultExpanded={false}> <div className="grid grid-cols-4 gap-2 w-full"> <BackgroundPreviews backgrounds={patternCraftGradients} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx index 303eb858..700eaea7 100644 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -82,6 +82,14 @@ function SoundEffectsView() { toggleSavedSound, showCommercialOnly, toggleCommercialFilter, + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, } = useSoundsStore(); const { results: searchResults, @@ -106,6 +114,55 @@ function SoundEffectsView() { useEffect(() => { loadSavedSounds(); + if (!hasLoaded) { + let ignore = false; + + const fetchTopSounds = async () => { + try { + if (!ignore) { + setLoading(true); + setError(null); + } + + const response = await fetch( + "/api/sounds/search?page_size=50&sort=downloads" + ); + + if (!ignore) { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const data = await response.json(); + setTopSoundEffects(data.results); + setHasLoaded(true); + + setCurrentPage(1); + setHasNextPage(!!data.next); + setTotalCount(data.count); + } + } catch (error) { + if (!ignore) { + console.error("Failed to fetch top sounds:", error); + setError( + error instanceof Error ? error.message : "Failed to load sounds" + ); + } + } finally { + if (!ignore) { + setLoading(false); + } + } + }; + + const timeoutId = setTimeout(fetchTopSounds, 100); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + } + if (scrollAreaRef.current && scrollPosition > 0) { const timeoutId = setTimeout(() => { scrollAreaRef.current?.scrollTo({ top: scrollPosition }); @@ -113,14 +170,23 @@ function SoundEffectsView() { return () => clearTimeout(timeoutId); } - }, []); + }, [ + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + ]); const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => { const { scrollTop } = event.currentTarget; setScrollPosition(scrollTop); handleScroll(event); }; - + const displayedSounds = useMemo(() => { const sounds = searchQuery ? searchResults : topSoundEffects; return sounds; diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index 41afe285..f67450cc 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -20,8 +20,7 @@ import { import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { Separator } from "@/components/ui/separator"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { Tooltip, TooltipContent, @@ -48,63 +47,41 @@ export function StickersView() { const { selectedCategory, setSelectedCategory } = useStickersStore(); return ( - <div className="h-full flex flex-col"> - <Tabs - value={selectedCategory} - onValueChange={(v) => { - if (["all", "general", "brands", "emoji"].includes(v)) { - setSelectedCategory(v as StickerCategory); - } - }} - className="flex flex-col h-full" - > - <div className="px-3 pt-4 pb-0"> - <TabsList> - <TabsTrigger value="all" className="gap-1"> - <Grid3X3 className="h-3 w-3" /> - All - </TabsTrigger> - <TabsTrigger value="general" className="gap-1"> - <Sparkles className="h-3 w-3" /> - Icons - </TabsTrigger> - <TabsTrigger value="brands" className="gap-1"> - <Hash className="h-3 w-3" /> - Brands - </TabsTrigger> - <TabsTrigger value="emoji" className="gap-1"> - <Smile className="h-3 w-3" /> - Emoji - </TabsTrigger> - </TabsList> - </div> - <Separator className="my-4" /> - <TabsContent - value="all" - className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0" - > - <StickersContentView category="all" /> - </TabsContent> - <TabsContent - value="general" - className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0" - > - <StickersContentView category="general" /> - </TabsContent> - <TabsContent - value="brands" - className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0" - > - <StickersContentView category="brands" /> - </TabsContent> - <TabsContent - value="emoji" - className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0" - > - <StickersContentView category="emoji" /> - </TabsContent> - </Tabs> - </div> + <BaseView + value={selectedCategory} + onValueChange={(v) => { + if (["all", "general", "brands", "emoji"].includes(v)) { + setSelectedCategory(v as StickerCategory); + } + }} + tabs={[ + { + value: "all", + label: "All", + icon: <Grid3X3 className="h-3 w-3" />, + content: <StickersContentView category="all" />, + }, + { + value: "general", + label: "Icons", + icon: <Sparkles className="h-3 w-3" />, + content: <StickersContentView category="general" />, + }, + { + value: "brands", + label: "Brands", + icon: <Hash className="h-3 w-3" />, + content: <StickersContentView category="brands" />, + }, + { + value: "emoji", + label: "Emoji", + icon: <Smile className="h-3 w-3" />, + content: <StickersContentView category="emoji" />, + }, + ]} + className="flex flex-col h-full p-0 overflow-hidden" + /> ); } @@ -204,12 +181,12 @@ function StickersContentView({ category }: { category: StickerCategory }) { setSelectedCollection, loadCollections, searchStickers, - downloadSticker, + addStickerToTimeline, clearRecentStickers, setSelectedCategory, + addingSticker, } = useStickersStore(); - const [addingSticker, setAddingSticker] = useState<string | null>(null); const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); const [collectionsToShow, setCollectionsToShow] = useState(20); const [showCollectionItems, setShowCollectionItems] = useState(false); @@ -273,48 +250,11 @@ function StickersContentView({ category }: { category: StickerCategory }) { }, [localSearchQuery]); const handleAddSticker = async (iconName: string) => { - if (!activeProject) { - toast.error("No active project"); - return; - } - - setAddingSticker(iconName); - try { - const file = await downloadSticker(iconName); - - if (!file) { - throw new Error("Failed to download sticker"); - } - - const mediaItem: Omit<MediaFile, "id"> = { - name: iconName.replace(":", "-"), - type: "image", - file, - url: URL.createObjectURL(file), - width: 200, - height: 200, - duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - ephemeral: false, - }; - - await addMediaFile(activeProject.id, mediaItem); - - const added = useMediaStore - .getState() - .mediaFiles.find( - (m) => m.url === mediaItem.url && m.name === mediaItem.name - ); - if (!added) throw new Error("Sticker not in media store"); - - addElementAtTime(added, currentTime); - - toast.success(`Added "${iconName}" to timeline`); + await addStickerToTimeline(iconName); } catch (error) { console.error("Failed to add sticker:", error); toast.error("Failed to add sticker to timeline"); - } finally { - setAddingSticker(null); } }; @@ -363,7 +303,7 @@ function StickersContentView({ category }: { category: StickerCategory }) { }, [isInCollection]); return ( - <div className="flex flex-col gap-5 mt-1 h-full"> + <div className="flex flex-col gap-5 mt-1 h-full p-4"> <div className="space-y-3"> <InputWithBack isExpanded={isInCollection} @@ -383,6 +323,7 @@ function StickersContentView({ category }: { category: StickerCategory }) { } value={localSearchQuery} onChange={setLocalSearchQuery} + disableAnimation={true} /> </div> diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx index ec01028c..6e92c616 100644 --- a/apps/web/src/components/editor/panel-base-view.tsx +++ b/apps/web/src/components/editor/panel-base-view.tsx @@ -1,6 +1,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
interface PanelBaseViewProps {
children?: React.ReactNode;
@@ -10,6 +11,7 @@ interface PanelBaseViewProps { tabs?: {
value: string;
label: string;
+ icon?: React.ReactNode;
content: React.ReactNode;
}[];
className?: string;
@@ -25,7 +27,7 @@ function ViewContent({ }) {
return (
<ScrollArea className="flex-1">
- <div className={`p-5 h-full ${className}`}>{children}</div>
+ <div className={cn("p-5", className)}>{children}</div>
</ScrollArea>
);
}
@@ -40,7 +42,7 @@ export function PanelBaseView({ ref,
}: PanelBaseViewProps) {
return (
- <div className={`h-full flex flex-col ${className}`} ref={ref}>
+ <div className={cn("h-full flex flex-col", className)} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
@@ -55,6 +57,11 @@ export function PanelBaseView({ <TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
+ {tab.icon ? (
+ <span className="inline-flex items-center mr-1">
+ {tab.icon}
+ </span>
+ ) : null}
{tab.label}
</TabsTrigger>
))}
@@ -68,7 +75,7 @@ export function PanelBaseView({ value={tab.value}
className="mt-0 flex-1 flex flex-col min-h-0"
>
- <ViewContent>{tab.content}</ViewContent>
+ <ViewContent className={className}>{tab.content}</ViewContent>
</TabsContent>
))}
</Tabs>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 1654624c..36f6a2ba 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -13,6 +13,8 @@ import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { useFrameCache } from "@/hooks/use-frame-cache"; +import { useSceneStore } from "@/stores/scene-store"; import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, @@ -42,8 +44,11 @@ export function PreviewPanel() { const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { isPlaying, volume, muted } = usePlaybackStore(); const { activeProject } = useProjectStore(); + const { currentScene } = useSceneStore(); const previewRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); + const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } = + useFrameCache(); const lastFrameTimeRef = useRef(0); const renderSeqRef = useRef(0); const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>( @@ -216,6 +221,16 @@ export function PreviewPanel() { }; }, [dragState, previewDimensions, canvasSize, updateTextElement]); + // Clear the frame cache when background settings change since they affect rendering + useEffect(() => { + invalidateCache(); + }, [ + mediaFiles, + activeProject?.backgroundColor, + activeProject?.backgroundType, + invalidateCache, + ]); + const handleTextMouseDown = ( e: React.MouseEvent<HTMLDivElement>, element: any, @@ -246,6 +261,7 @@ export function PreviewPanel() { }, []); const hasAnyElements = tracks.some((track) => track.elements.length > 0); + const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType; const getActiveElements = (): ActiveElement[] => { const activeElements: ActiveElement[] = []; @@ -449,7 +465,7 @@ export function PreviewPanel() { }; }, [isPlaying, volume, muted, mediaFiles]); - // Canvas: draw current frame for visible elements using offscreen compositing + // Canvas: draw current frame with caching useEffect(() => { const draw = async () => { const canvas = canvasRef.current; @@ -475,10 +491,94 @@ export function PreviewPanel() { lastFrameTimeRef.current = currentTime; } - // Invalidate older async renders when user scrubs rapidly - const mySeq = (renderSeqRef.current += 1); + const cachedFrame = getCachedFrame( + currentTime, + tracks, + mediaFiles, + activeProject, + currentScene?.id + ); + if (cachedFrame) { + mainCtx.putImageData(cachedFrame, 0, 0); + + // Pre-render nearby frames in background + if (!isPlaying) { + // Only during scrubbing to avoid interfering with playback + preRenderNearbyFrames( + currentTime, + tracks, + mediaFiles, + activeProject, + async (time: number) => { + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = displayWidth; + tempCanvas.height = displayHeight; + const tempCtx = tempCanvas.getContext("2d"); + if (!tempCtx) + throw new Error("Failed to create temp canvas context"); - // Offscreen buffer to avoid flicker (reuse canvas) + await renderTimelineFrame({ + ctx: tempCtx, + time, + canvasWidth: displayWidth, + canvasHeight: displayHeight, + tracks, + mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, + backgroundColor: + activeProject?.backgroundType === "blur" + ? undefined + : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + return tempCtx.getImageData(0, 0, displayWidth, displayHeight); + }, + currentScene?.id, + 3 + ); + } else { + // Small lookahead while playing + preRenderNearbyFrames( + currentTime, + tracks, + mediaFiles, + activeProject, + async (time: number) => { + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = displayWidth; + tempCanvas.height = displayHeight; + const tempCtx = tempCanvas.getContext("2d"); + if (!tempCtx) + throw new Error("Failed to create temp canvas context"); + + await renderTimelineFrame({ + ctx: tempCtx, + time, + canvasWidth: displayWidth, + canvasHeight: displayHeight, + tracks, + mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, + backgroundColor: + activeProject?.backgroundType === "blur" + ? undefined + : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + return tempCtx.getImageData(0, 0, displayWidth, displayHeight); + }, + currentScene?.id, + 1 + ); + } + return; + } + + // Cache miss - render from scratch if (!offscreenCanvasRef.current) { const hasOffscreen = typeof (globalThis as unknown as { OffscreenCanvas?: unknown }) @@ -534,13 +634,30 @@ export function PreviewPanel() { canvasHeight: displayHeight, tracks, mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); + const imageData = (offCtx as CanvasRenderingContext2D).getImageData( + 0, + 0, + displayWidth, + displayHeight + ); + cacheFrame( + currentTime, + imageData, + tracks, + mediaFiles, + activeProject, + currentScene?.id + ); + // Blit offscreen to visible canvas mainCtx.clearRect(0, 0, displayWidth, displayHeight); if ((offscreenCanvas as HTMLCanvasElement).getContext) { @@ -564,6 +681,10 @@ export function PreviewPanel() { canvasSize.height, activeProject?.backgroundType, activeProject?.backgroundColor, + getCachedFrame, + cacheFrame, + preRenderNearbyFrames, + isPlaying, ]); // Get media elements for blur background (video/image only) @@ -593,7 +714,7 @@ export function PreviewPanel() { className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0" > <div className="flex-1" /> - {hasAnyElements ? ( + {shouldRenderPreview ? ( <div ref={previewRef} className="relative overflow-hidden border" @@ -619,9 +740,7 @@ export function PreviewPanel() { aria-label="Video preview canvas" /> {activeElements.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> - No elements at current time - </div> + <></> ) : ( activeElements.map((elementData) => renderElement(elementData)) )} diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx new file mode 100644 index 00000000..b5432b53 --- /dev/null +++ b/apps/web/src/components/editor/scenes-view.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { useSceneStore } from "@/stores/scene-store"; +import { Check, ListCheck, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogTrigger, +} from "@/components/ui/dialog"; + +export function ScenesView({ children }: { children: React.ReactNode }) { + const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore(); + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set()); + + const handleSceneSwitch = async (sceneId: string) => { + if (isSelectMode) { + toggleSceneSelection(sceneId); + return; + } + + try { + await switchToScene({ sceneId }); + } catch (error) { + console.error("Failed to switch scene:", error); + } + }; + + const toggleSceneSelection = (sceneId: string) => { + setSelectedScenes((prev) => { + const newSet = new Set(prev); + if (newSet.has(sceneId)) { + newSet.delete(sceneId); + } else { + newSet.add(sceneId); + } + return newSet; + }); + }; + + const handleSelectMode = () => { + setIsSelectMode(!isSelectMode); + setSelectedScenes(new Set()); + }; + + const handleDeleteSelected = async () => { + for (const sceneId of selectedScenes) { + const scene = scenes.find((s) => s.id === sceneId); + if (scene && !scene.isMain) { + try { + await deleteScene({ sceneId }); + } catch (error) { + console.error("Failed to delete scene:", error); + } + } + } + setSelectedScenes(new Set()); + setIsSelectMode(false); + }; + + return ( + <Sheet> + <SheetTrigger asChild>{children}</SheetTrigger> + <SheetContent> + <SheetHeader> + <SheetTitle> + {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"} + </SheetTitle> + <SheetDescription> + {isSelectMode + ? "Select scenes to delete" + : "Switch between scenes in your project"} + </SheetDescription> + </SheetHeader> + <div className="py-4 flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Button + className="rounded-md" + variant={isSelectMode ? "default" : "outline"} + size="sm" + onClick={handleSelectMode} + > + <ListCheck /> + {isSelectMode ? "Cancel" : "Select"} + </Button> + {isSelectMode && ( + <DeleteDialog + count={selectedScenes.size} + onDelete={handleDeleteSelected} + disabled={Array.from(selectedScenes).some( + (id) => scenes.find((s) => s.id === id)?.isMain + )} + > + <Button className="rounded-md" variant="destructive" size="sm"> + <Trash2 /> + Delete ({selectedScenes.size}) + </Button> + </DeleteDialog> + )} + </div> + {scenes.length === 0 ? ( + <div className="text-sm text-muted-foreground"> + No scenes available + </div> + ) : ( + <div className="space-y-2"> + {scenes.map((scene) => ( + <Button + key={scene.id} + variant="outline" + className={cn( + "w-full justify-between font-normal", + currentScene?.id === scene.id && + !isSelectMode && + "border-primary !text-primary", + isSelectMode && + selectedScenes.has(scene.id) && + "bg-accent border-foreground/30" + )} + onClick={() => handleSceneSwitch(scene.id)} + > + <span>{scene.name}</span> + <div className="flex items-center gap-2"> + {((isSelectMode && selectedScenes.has(scene.id)) || + (!isSelectMode && currentScene?.id === scene.id)) && ( + <Check className="h-4 w-4" /> + )} + </div> + </Button> + ))} + </div> + )} + </div> + </SheetContent> + </Sheet> + ); +} + +function DeleteDialog({ + count, + onDelete, + disabled, + children, +}: { + count: number; + onDelete: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + const handleDelete = () => { + onDelete(); + setOpen(false); + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Delete Scenes</DialogTitle> + <DialogDescription> + Are you sure you want to delete {count} scene + {count === 1 ? "" : "s"}? This action cannot be undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={disabled} + > + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} 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> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx new file mode 100644 index 00000000..7def2985 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { TimelineTrack } from "@/types/timeline"; +import { MediaFile } from "@/types/media"; +import { TProject } from "@/types/project"; +import { useSceneStore } from "@/stores/scene-store"; + +interface CacheSegment { + startTime: number; + endTime: number; + cached: boolean; +} + +interface TimelineCacheIndicatorProps { + duration: number; + zoomLevel: number; + tracks: TimelineTrack[]; + mediaFiles: MediaFile[]; + activeProject: TProject | null; + getRenderStatus: ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + sceneId?: string + ) => "cached" | "not-cached"; +} + +export function TimelineCacheIndicator({ + duration, + zoomLevel, + tracks, + mediaFiles, + activeProject, + getRenderStatus, +}: TimelineCacheIndicatorProps) { + const { currentScene } = useSceneStore(); + + // Calculate cache segments by sampling the timeline + const calculateCacheSegments = (): CacheSegment[] => { + const segments: CacheSegment[] = []; + const sampleRate = 10; // Sample every 0.1 seconds + const totalSamples = Math.ceil(duration * sampleRate); + + if (totalSamples === 0) { + return [{ startTime: 0, endTime: duration, cached: false }]; + } + + let currentSegment: CacheSegment | null = null; + + for (let i = 0; i <= totalSamples; i++) { + const time = i / sampleRate; + const cached = + getRenderStatus( + time, + tracks, + mediaFiles, + activeProject, + currentScene?.id + ) === "cached"; + + if (!currentSegment) { + // Start first segment + currentSegment = { + startTime: time, + endTime: time, + cached, + }; + } else if (currentSegment.cached === cached) { + // Extend current segment + currentSegment.endTime = time; + } else { + // Finish current segment and start new one + segments.push(currentSegment); + currentSegment = { + startTime: time, + endTime: time, + cached, + }; + } + } + + // Add the last segment + if (currentSegment) { + currentSegment.endTime = duration; + segments.push(currentSegment); + } + + return segments; + }; + + const cacheSegments = calculateCacheSegments(); + + return ( + <div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none"> + {cacheSegments.map((segment, index) => { + const startX = + segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const endX = + segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const width = endX - startX; + + return ( + <div + key={index} + className={cn( + "absolute top-0 h-px", + segment.cached ? "bg-primary" : "bg-transparent" + )} + style={{ + left: `${startX}px`, + width: `${width}px`, + }} + title={ + segment.cached + ? "Cached (fast playback)" + : "Not cached (will render)" + } + /> + ); + })} + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 677b9c61..ca47d3ca 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; -import { TimelineElementProps } from "@/types/timeline"; +import { TimelineElementProps, MediaElement } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, @@ -42,16 +42,17 @@ export function TimelineElement({ }: TimelineElementProps) { const { mediaFiles } = useMediaStore(); const { - removeElementFromTrack, - removeElementFromTrackWithRipple, dragState, - splitElement, - addElementToTrack, - replaceElementMedia, - rippleEditingEnabled, - toggleElementHidden, - toggleElementMuted, copySelected, + selectedElements, + deleteSelected, + splitSelected, + toggleSelectedHidden, + toggleSelectedMuted, + duplicateElement, + revealElementInMedia, + replaceElementWithFile, + getContextMenuState, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); @@ -68,7 +69,12 @@ export function TimelineElement({ zoomLevel, }); - const { requestRevealMedia } = useMediaPanelStore.getState(); + const { + isMultipleSelected, + isCurrentElementSelected, + hasAudioElements, + canSplitSelected, + } = getContextMenuState(track.id, element.id); const effectiveDuration = element.duration - element.trimStart - element.trimEnd; @@ -77,44 +83,26 @@ export function TimelineElement({ effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel ); - // Use real-time position during drag, otherwise use stored position const isBeingDragged = dragState.elementId === element.id; const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime : element.startTime; - // Element should always be positioned at startTime - trimStart only affects content, not position const elementLeft = elementStartTime * 50 * zoomLevel; const handleElementSplitContext = (e: React.MouseEvent) => { e.stopPropagation(); - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const secondElementId = splitElement(track.id, element.id, currentTime); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } + splitSelected( + currentTime, + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); }; const handleElementDuplicateContext = (e: React.MouseEvent) => { e.stopPropagation(); - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); + duplicateElement(track.id, element.id); }; const handleElementCopyContext = (e: React.MouseEvent) => { @@ -124,49 +112,38 @@ export function TimelineElement({ const handleElementDeleteContext = (e: React.MouseEvent) => { e.stopPropagation(); - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(track.id, element.id); - } else { - removeElementFromTrack(track.id, element.id); - } + deleteSelected( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); }; const handleToggleElementContext = (e: React.MouseEvent) => { e.stopPropagation(); + if (hasAudio && element.type === "media") { - toggleElementMuted(track.id, element.id); - return; + toggleSelectedMuted( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); + } else { + toggleSelectedHidden( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); } - toggleElementHidden(track.id, element.id); }; const handleReplaceClip = (e: React.MouseEvent) => { e.stopPropagation(); - if (element.type !== "media") { - toast.error("Replace is only available for media clips"); - return; - } - // Create a file input to select replacement media const input = document.createElement("input"); input.type = "file"; input.accept = "video/*,audio/*,image/*"; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - try { - const result = await replaceElementMedia(track.id, element.id, file); - if (result.success) { - toast.success("Clip replaced successfully"); - } else { - toast.error(result.error || "Failed to replace clip"); - } - } catch (error) { - console.error("Unexpected error replacing clip:", error); - toast.error( - `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` - ); + if (file) { + await replaceElementWithFile(track.id, element.id, file); } }; input.click(); @@ -174,12 +151,7 @@ export function TimelineElement({ const handleRevealInMedia = (e: React.MouseEvent) => { e.stopPropagation(); - if (element.type !== "media") { - toast.error("Reveal is only available for media clips"); - return; - } - - requestRevealMedia(element.mediaId); + revealElementInMedia(element.id); }; const renderElementContent = () => { @@ -191,7 +163,6 @@ export function TimelineElement({ ); } - // Render media element -> const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); if (!mediaItem) { return ( @@ -201,20 +172,15 @@ export function TimelineElement({ ); } - const TILE_ASPECT_RATIO = 16 / 9; - if ( mediaItem.type === "image" || (mediaItem.type === "video" && mediaItem.thumbnailUrl) ) { - // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight; - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + const tileWidth = trackHeight * (16 / 9); const imageUrl = mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; - const isImage = mediaItem.type === "image"; return ( <div className="w-full h-full flex items-center justify-center"> @@ -228,18 +194,17 @@ export function TimelineElement({ style={{ backgroundImage: imageUrl ? `url(${imageUrl})` : "none", backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundSize: `${tileWidth}px ${trackHeight}px`, backgroundPosition: "left center", pointerEvents: "none", }} - aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`} + aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`} /> </div> </div> ); } - // Render audio element -> if (mediaItem.type === "audio") { return ( <div className="w-full h-full flex items-center gap-2"> @@ -267,7 +232,7 @@ export function TimelineElement({ } }; - const isMuted = element.type === "media" ? element.muted === true : false; + const isMuted = element.type === "media" && element.muted; return ( <ContextMenu> @@ -332,16 +297,33 @@ export function TimelineElement({ </div> </ContextMenuTrigger> <ContextMenuContent className="z-200"> - <ContextMenuItem onClick={handleElementSplitContext}> - <Scissors className="h-4 w-4 mr-2" /> - Split at playhead - </ContextMenuItem> + {(!isMultipleSelected || + (isMultipleSelected && + isCurrentElementSelected && + canSplitSelected)) && ( + <ContextMenuItem onClick={handleElementSplitContext}> + <Scissors className="h-4 w-4 mr-2" /> + {isMultipleSelected && isCurrentElementSelected + ? `Split ${selectedElements.length} elements at playhead` + : "Split at playhead"} + </ContextMenuItem> + )} + <ContextMenuItem onClick={handleElementCopyContext}> <Copy className="h-4 w-4 mr-2" /> - Copy element + {isMultipleSelected && isCurrentElementSelected + ? `Copy ${selectedElements.length} elements` + : "Copy element"} </ContextMenuItem> + <ContextMenuItem onClick={handleToggleElementContext}> - {hasAudio ? ( + {isMultipleSelected && isCurrentElementSelected ? ( + hasAudioElements ? ( + <VolumeX className="h-4 w-4 mr-2" /> + ) : ( + <EyeOff className="h-4 w-4 mr-2" /> + ) + ) : hasAudio ? ( isMuted ? ( <Volume2 className="h-4 w-4 mr-2" /> ) : ( @@ -353,21 +335,29 @@ export function TimelineElement({ <EyeOff className="h-4 w-4 mr-2" /> )} <span> - {hasAudio - ? isMuted - ? "Unmute" - : "Mute" - : element.hidden - ? "Show" - : "Hide"}{" "} - {element.type === "text" ? "text" : "clip"} + {isMultipleSelected && isCurrentElementSelected + ? hasAudioElements + ? `Toggle mute ${selectedElements.length} elements` + : `Toggle visibility ${selectedElements.length} elements` + : hasAudio + ? isMuted + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {!isMultipleSelected && (element.type === "text" ? "text" : "clip")} </span> </ContextMenuItem> - <ContextMenuItem onClick={handleElementDuplicateContext}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - {element.type === "media" && ( + + {!isMultipleSelected && ( + <ContextMenuItem onClick={handleElementDuplicateContext}> + <Copy className="h-4 w-4 mr-2" /> + Duplicate {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + )} + + {!isMultipleSelected && element.type === "media" && ( <> <ContextMenuItem onClick={handleRevealInMedia}> <Search className="h-4 w-4 mr-2" /> @@ -379,13 +369,17 @@ export function TimelineElement({ </ContextMenuItem> </> )} + <ContextMenuSeparator /> + <ContextMenuItem onClick={handleElementDeleteContext} className="text-destructive focus:text-destructive" > <Trash2 className="h-4 w-4 mr-2" /> - Delete {element.type === "text" ? "text" : "clip"} + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedElements.length} elements` + : `Delete ${element.type === "text" ? "text" : "clip"}`} </ContextMenuItem> </ContextMenuContent> </ContextMenu> diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx new file mode 100644 index 00000000..1351a457 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx @@ -0,0 +1,67 @@ +"use client";
+
+import { cn } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+interface TimelineMarkerProps {
+ time: number;
+ zoomLevel: number;
+ interval: number;
+ isMainMarker: boolean;
+}
+
+export function TimelineMarker({
+ time,
+ zoomLevel,
+ interval,
+ isMainMarker,
+}: TimelineMarkerProps) {
+ return (
+ <div
+ className={cn(
+ "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={cn(
+ "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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx new file mode 100644 index 00000000..80c6ce08 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -0,0 +1,397 @@ +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useSceneStore } from "@/stores/scene-store"; +import { toast } from "sonner"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { + Pause, + Play, + SkipBack, + Bookmark, + Magnet, + Link, + ZoomOut, + ZoomIn, + Copy, + Trash2, + Snowflake, + ArrowLeftToLine, + ArrowRightToLine, + SplitSquareHorizontal, + Scissors, + LayersIcon, +} from "lucide-react"; +import { + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, +} from "@/components/ui/split-button"; +import { Slider } from "@/components/ui/slider"; +import { DEFAULT_FPS } from "@/stores/project-store"; +import { formatTimeCode } from "@/lib/time"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { ScenesView } from "../scenes-view"; + +export function TimelineToolbar({ + zoomLevel, + setZoomLevel, +}: { + zoomLevel: number; + setZoomLevel: (zoom: number) => void; +}) { + const { + tracks, + addTrack, + addElementToTrack, + selectedElements, + clearSelectedElements, + deleteSelected, + splitSelected, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); + const { scenes, currentScene } = useSceneStore(); + + const handleSplitSelected = () => { + splitSelected(currentTime); + }; + + 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 = () => { + deleteSelected(); + }; + + 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="flex items-center justify-between px-2 py-1 border-b h-10"> + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggle}> + {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)}> + <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> + <SplitButton className="border border-foreground/10"> + <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> + <SplitButtonSeparator /> + <ScenesView> + <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}> + <LayersIcon /> + </SplitButtonRight> + </ScenesView> + </SplitButton> + </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> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 42c78aa4..7cfde931 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1147,7 +1147,7 @@ export function TimelineTrackContent({ const handleElementSplit = () => { const { currentTime } = usePlaybackStore(); - const { splitElement } = useTimelineStore(); + const { splitSelected } = useTimelineStore(); const splitTime = currentTime; const effectiveStart = element.startTime; const effectiveEnd = @@ -1155,14 +1155,7 @@ export function TimelineTrackContent({ (element.duration - element.trimStart - element.trimEnd); if (splitTime > effectiveStart && splitTime < effectiveEnd) { - const secondElementId = splitElement( - track.id, - element.id, - splitTime - ); - if (!secondElementId) { - toast.error("Failed to split element"); - } + splitSelected(splitTime, track.id, element.id); } else { toast.error("Playhead must be within element to split"); } @@ -1182,8 +1175,8 @@ export function TimelineTrackContent({ }; const handleElementDelete = () => { - const { removeElementFromTrack } = useTimelineStore.getState(); - removeElementFromTrack(track.id, element.id); + const { deleteSelected } = useTimelineStore.getState(); + deleteSelected(track.id, element.id); }; return ( |
