diff options
49 files changed, 3814 insertions, 1283 deletions
diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc index 4a6b29fe..4be535af 100644 --- a/.cursor/rules/ultracite.mdc +++ b/.cursor/rules/ultracite.mdc @@ -5,15 +5,18 @@ alwaysApply: true --- # Project Context + Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. ## Key Principles + - Zero configuration required - Subsecond performance - Maximum type safety - AI-friendly code generation ## Before Writing Code + 1. Analyze existing patterns in the codebase 2. Consider edge cases and error scenarios 3. Follow the rules below strictly @@ -23,12 +26,14 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c ## Rules ### Accessibility (a11y) + - Always include a `title` element for icons unless there's text beside the icon. - Always include a `type` attribute for button elements. - Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. - Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. ### Code Complexity and Quality + - Don't use primitive type aliases or misleading types. - Don't use the comma operator. - Use for...of statements instead of Array.forEach. @@ -36,6 +41,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use .flatMap() instead of map().flat() when possible. ### React and JSX Best Practices + - Don't import `React` itself. - Don't define React components inside other components. - Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. @@ -43,11 +49,13 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use `<>...</>` instead of `<Fragment>...</Fragment>`. ### Function Parameters and Props + - Always use destructured props objects instead of individual parameters in functions. - Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`. - This applies to all functions, not just React components. ### Correctness and Safety + - Don't assign a value to itself. - Avoid unused imports and variables. - Don't use await inside loops. @@ -55,13 +63,16 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Don't use the TypeScript directive @ts-ignore. - Make sure the `preconnect` attribute is used when using Google Fonts. - Don't use the `delete` operator. +- Don't use `require()` in TypeScript/ES modules - use proper `import` statements. ### TypeScript Best Practices + - Don't use TypeScript enums. - Use either `T[]` or `Array<T>` consistently. - Don't use the `any` type. ### Style and Consistency + - Don't use global `eval()`. - Use `String.slice()` instead of `String.substr()` and `String.substring()`. - Don't use `else` blocks when the `if` block breaks early. @@ -70,17 +81,19 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. ### Next.js Specific Rules + - Don't use `<img>` elements in Next.js projects. - Don't use `<head>` elements in Next.js projects. ## Example: Error Handling + ```typescript // ✅ Good: Comprehensive error handling try { const result = await fetchData(); return { success: true, data: result }; } catch (error) { - console.error('API call failed:', error); + console.error("API call failed:", error); return { success: false, error: error.message }; } diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx index 151f7f94..c37e78b7 100644 --- a/apps/web/src/app/editor/[project_id]/layout.tsx +++ b/apps/web/src/app/editor/[project_id]/layout.tsx @@ -1,13 +1,9 @@ -"use client";
-
-import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher";
-
-export default function EditorLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- useGlobalPrefetcher();
-
- return <div>{children}</div>;
-}
+"use client"; + +export default function EditorLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <div>{children}</div>; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ee0bdbd2..7e32ea22 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { TooltipProvider } from "../components/ui/tooltip"; import { StorageProvider } from "../components/storage-provider"; +import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator"; import { baseMetaData } from "./metadata"; import { defaultFont } from "../lib/font-config"; import { BotIdClient } from "botid/client"; @@ -36,7 +37,9 @@ export default function RootLayout({ <body className={`${defaultFont.className} font-sans antialiased`}> <ThemeProvider attribute="class" defaultTheme="dark"> <TooltipProvider> - <StorageProvider>{children}</StorageProvider> + <StorageProvider> + <ScenesMigrator>{children}</ScenesMigrator> + </StorageProvider> <Analytics /> <Toaster /> <Script diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index b26da5aa..b5397b63 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -47,8 +47,8 @@ const roadmapItems: { description: "The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.", status: { - text: "In Progress", - type: "pending", + text: "Completed", + type: "complete", }, }, { 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 ( diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 0087502e..176452cd 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -2,28 +2,11 @@ import { motion } from "motion/react"; import Link from "next/link"; -import { useEffect, useState } from "react"; import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri"; import { FaGithub } from "react-icons/fa6"; -import { getStars } from "@/lib/fetch-github-stars"; import Image from "next/image"; export function Footer() { - const [star, setStar] = useState<string>(); - - useEffect(() => { - const fetchStars = async () => { - try { - const data = await getStars(); - setStar(data); - } catch (err) { - console.error("Failed to fetch GitHub stars", err); - } - }; - - fetchStars(); - }, []); - return ( <motion.footer className="bg-background border-t" diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index dd6a500b..689c3ee3 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -111,7 +111,7 @@ export function KeyboardShortcutsHelp() { </Button> </DialogTrigger> <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0"> - <DialogHeader className="flex-shrink-0 p-6 pb-4"> + <DialogHeader className="flex-shrink-0 p-6 pb-0"> <DialogTitle className="flex items-center gap-2"> <Keyboard className="w-5 h-5" /> Keyboard Shortcuts @@ -123,7 +123,7 @@ export function KeyboardShortcutsHelp() { </DialogHeader> <div className="overflow-y-auto flex-grow scrollbar-thin"> - <div className="space-y-6 p-6"> + <div className="space-y-6 p-6 pt-2"> {categories.map((category) => ( <div key={category} className="flex flex-col gap-1"> <h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium"> diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts deleted file mode 100644 index 2d7c684c..00000000 --- a/apps/web/src/components/providers/global-prefetcher.ts +++ /dev/null @@ -1,78 +0,0 @@ -"use client";
-
-import { useEffect } from "react";
-import { useSoundsStore } from "@/stores/sounds-store";
-
-export function useGlobalPrefetcher() {
- const {
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- } = useSoundsStore();
-
- useEffect(() => {
- if (hasLoaded) return;
-
- let ignore = false;
-
- const prefetchTopSounds = 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);
-
- // Set pagination state for top sounds
- setCurrentPage(1);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- }
- } catch (error) {
- if (!ignore) {
- console.error("Failed to prefetch top sounds:", error);
- setError(
- error instanceof Error ? error.message : "Failed to load sounds"
- );
- }
- } finally {
- if (!ignore) {
- setLoading(false);
- }
- }
- };
-
- const timeoutId = setTimeout(prefetchTopSounds, 100);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- ]);
-}
diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx new file mode 100644 index 00000000..b984a7f4 --- /dev/null +++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { storageService } from "@/lib/storage/storage-service"; +import { TProject, Scene } from "@/types/project"; +import { generateUUID } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Progress } from "@/components/ui/progress"; +import { createMainScene } from "@/stores/project-store"; + +interface MigrationProgress { + current: number; + total: number; + currentProjectName: string; +} + +export function ScenesMigrator({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const [isMigrating, setIsMigrating] = useState(false); + const [progress, setProgress] = useState<MigrationProgress>({ + current: 0, + total: 0, + currentProjectName: "", + }); + + const shouldCheckMigration = + pathname.startsWith("/editor") || pathname.startsWith("/projects"); + + useEffect(() => { + if (!shouldCheckMigration) return; + + checkAndMigrateProjects(); + }, [shouldCheckMigration]); + + const checkAndMigrateProjects = async () => { + try { + const projects = await storageService.loadAllProjects(); + const legacyProjects = projects.filter( + (project) => !project.scenes || project.scenes.length === 0 + ); + + if (legacyProjects.length === 0) { + // No migration needed + return; + } + + setIsMigrating(true); + setProgress({ + current: 0, + total: legacyProjects.length, + currentProjectName: "", + }); + + // Migrate each legacy project + for (let i = 0; i < legacyProjects.length; i++) { + const project = legacyProjects[i]; + + setProgress({ + current: i, + total: legacyProjects.length, + currentProjectName: project.name, + }); + + await migrateLegacyProject(project); + } + + setProgress({ + current: legacyProjects.length, + total: legacyProjects.length, + currentProjectName: "Complete!", + }); + + setTimeout(() => { + setIsMigrating(false); + }, 1000); + } catch (error) { + console.error("Migration failed:", error); + setIsMigrating(false); + } + }; + + const migrateLegacyProject = async (project: TProject) => { + try { + const mainScene: Scene = { + id: generateUUID(), + name: "Main Scene", + isMain: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const migratedProject: TProject = { + ...project, + scenes: [mainScene], + currentSceneId: mainScene.id, + updatedAt: new Date(), + }; + + // Load existing timeline data (legacy format) + const legacyTimeline = await storageService.loadTimeline({ + projectId: project.id, + }); + + await storageService.saveProject({ project: migratedProject }); + + // If timeline data, migrate it to the main scene + if (legacyTimeline && legacyTimeline.length > 0) { + await storageService.saveTimeline({ + projectId: project.id, + tracks: legacyTimeline, + sceneId: mainScene.id, + }); + } + + // Clean up legacy timeline storage + await storageService.deleteProjectTimeline({ projectId: project.id }); + } catch (error) { + console.error(`Failed to migrate project ${project.name}:`, error); + throw error; + } + }; + + if (!shouldCheckMigration) { + return children; + } + + if (isMigrating) { + const progressPercent = + progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + + return ( + <Dialog open={true}> + <DialogContent + className="sm:max-w-md" + onPointerDownOutside={(e) => e.preventDefault()} + > + <DialogHeader> + <DialogTitle>Updating Projects</DialogTitle> + <DialogDescription> + We're adding scene support to your projects. This will only take a + moment. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>Progress</span> + <span> + {progress.current} of {progress.total} + </span> + </div> + <Progress value={progressPercent} className="w-full" /> + </div> + + {progress.currentProjectName && ( + <div className="text-sm text-muted-foreground"> + {progress.current < progress.total + ? `Updating: ${progress.currentProjectName}` + : progress.currentProjectName} + </div> + )} + </div> + </DialogContent> + </Dialog> + ); + } + + return children; +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0e07ccb8..56a10f44 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -16,9 +16,9 @@ const buttonVariants = cva( "primary-gradient": "bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity", destructive: - "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", + "bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground", outline: - "border border-input bg-background shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", + "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input", text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index ad7e825b..42d0f80d 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef< <DialogPrimitive.Overlay ref={ref} className={cn( - "fixed inset-0 z-150 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "fixed inset-0 z-250 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className )} {...props} @@ -39,7 +39,7 @@ const DialogContent = React.forwardRef< <DialogPrimitive.Content ref={ref} className={cn( - "fixed left-[50%] top-[50%] p-6 z-150 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg", + "fixed left-[50%] top-[50%] p-6 z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg", className )} onCloseAutoFocus={(e) => { diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index c238b33c..9f5c7887 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -218,7 +218,7 @@ function PlusButton({ <Button size="icon" className={cn( - "absolute bottom-2 right-2 size-4 bg-background hover:bg-panel text-foreground", + "absolute bottom-2 right-2 size-5 bg-background hover:bg-panel text-foreground", className )} onClick={(e) => { @@ -228,7 +228,7 @@ function PlusButton({ }} title={tooltipText} > - <Plus className="size-3!" /> + <Plus className="size-4!" /> </Button> ); diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index ea225c14..cae9f8c9 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -12,6 +12,7 @@ interface InputWithBackProps { placeholder?: string;
value?: string;
onChange?: (value: string) => void;
+ disableAnimation?: boolean;
}
export function InputWithBack({
@@ -20,12 +21,13 @@ export function InputWithBack({ placeholder = "Search anything",
value,
onChange,
+ disableAnimation = false,
}: InputWithBackProps) {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [buttonOffset, setButtonOffset] = useState(-60);
const smoothTransition = {
- duration: 0.35,
+ duration: disableAnimation ? 0 : 0.35,
ease: [0.25, 0.1, 0.25, 1] as const,
};
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 47782a7f..406ff8eb 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 2c5522c1..f97bd438 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( <SheetPrimitive.Overlay className={cn( - "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "fixed inset-0 z-200 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className )} {...props} @@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef< SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; const sheetVariants = cva( - "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out", + "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out", { variants: { side: { @@ -62,10 +62,14 @@ const SheetContent = React.forwardRef< <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} + onOpenAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} {...props} > - <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> - <X className="h-4 w-4" /> + <SheetPrimitive.Close className="absolute cursor-pointer right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> + <X className="size-5" /> <span className="sr-only">Close</span> </SheetPrimitive.Close> {children} diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx new file mode 100644 index 00000000..ca3b518c --- /dev/null +++ b/apps/web/src/components/ui/split-button.tsx @@ -0,0 +1,90 @@ +import { Button, ButtonProps } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { ReactNode, forwardRef } from "react";
+import { cn } from "@/lib/utils";
+
+interface SplitButtonProps {
+ children: ReactNode;
+ className?: string;
+}
+
+interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
+ children: ReactNode;
+}
+
+const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
+ ({ children, className, ...props }, ref) => {
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "inline-flex rounded-full h-7 border border-input bg-panel-accent overflow-hidden",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+ }
+);
+SplitButton.displayName = "SplitButton";
+
+const SplitButtonSide = forwardRef<
+ HTMLButtonElement,
+ SplitButtonSideProps & { paddingClass: string }
+>(({ children, className, paddingClass, onClick, ...props }, ref) => {
+ return (
+ <Button
+ ref={ref}
+ variant="text"
+ className={cn(
+ "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors disabled:text-muted-foreground",
+ onClick
+ ? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer"
+ : "cursor-default select-text",
+ paddingClass,
+ className
+ )}
+ onClick={onClick}
+ {...props}
+ >
+ {typeof children === "string" ? (
+ <span className="font-normal cursor-text">{children}</span>
+ ) : (
+ children
+ )}
+ </Button>
+ );
+});
+SplitButtonSide.displayName = "SplitButtonSide";
+
+const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
+ }
+);
+SplitButtonLeft.displayName = "SplitButtonLeft";
+
+const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
+ }
+);
+SplitButtonRight.displayName = "SplitButtonRight";
+
+const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
+ ({ className, ...props }, ref) => {
+ return (
+ <Separator
+ ref={ref}
+ orientation="vertical"
+ className={cn("h-full bg-foreground/15", className)}
+ {...props}
+ />
+ );
+ }
+);
+SplitButtonSeparator.displayName = "SplitButtonSeparator";
+
+export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx index 6a7677b4..50c7e7cc 100644 --- a/apps/web/src/data/colors/syntax-ui.tsx +++ b/apps/web/src/data/colors/syntax-ui.tsx @@ -1,32 +1,32 @@ -// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
-
-export const syntaxUIGradients = [
- // Cyan to Blue gradients
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
- "linear-gradient(to right, #bfdbfe, #a5f3fc)",
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
-
- // Purple gradients
- "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
- "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
-
- // Blue gradients
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
-
- // Green gradients
- "linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
- "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
-
- // Red gradient
- "linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
-
- // Yellow/Orange gradient
- "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
-
- // Pink gradient
- "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
-
- // Neon radial gradient
- "radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
-];
+// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients) + +export const syntaxUIGradients = [ + // Cyan to Blue gradients + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + "linear-gradient(to right, #bfdbfe, #a5f3fc)", + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + + // Purple gradients + "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)", + "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)", + + // Blue gradients + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + + // Green gradients + "linear-gradient(to right, #6ee7b7, #34d399, #10b981)", + "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)", + + // Red gradient + "linear-gradient(to right, #fca5a5, #f87171, #ef4444)", + + // Yellow/Orange gradient + "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)", + + // Pink gradient + "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)", + + // Neon radial gradient + "radial-gradient(circle at bottom left, #ff00ff, #00ffff)", +]; diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index 974f9db5..9287d2e0 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -13,8 +13,8 @@ export function useEditorActions() { selectedElements, clearSelectedElements, setSelectedElements, - removeElementFromTrack, - splitElement, + deleteSelected, + splitSelected, addElementToTrack, snappingEnabled, toggleSnapping, @@ -135,7 +135,7 @@ export function useEditorActions() { (element.duration - element.trimStart - element.trimEnd); if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitElement(trackId, elementId, currentTime); + splitSelected(currentTime, trackId, elementId); } else { toast.error("Playhead must be within selected element"); } @@ -150,12 +150,7 @@ export function useEditorActions() { if (selectedElements.length === 0) { return; } - selectedElements.forEach( - ({ trackId, elementId }: { trackId: string; elementId: string }) => { - removeElementFromTrack(trackId, elementId); - } - ); - clearSelectedElements(); + deleteSelected(); }, undefined ); diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts new file mode 100644 index 00000000..cce187f1 --- /dev/null +++ b/apps/web/src/hooks/use-frame-cache.ts @@ -0,0 +1,348 @@ +import { useRef, useCallback } from "react"; +import { + TimelineTrack, + TimelineElement, + MediaElement, + TextElement, +} from "@/types/timeline"; +import { MediaFile } from "@/types/media"; +import { TProject } from "@/types/project"; + +interface CachedFrame { + imageData: ImageData; + timelineHash: string; + timestamp: number; +} + +interface FrameCacheOptions { + maxCacheSize?: number; // Maximum number of cached frames + cacheResolution?: number; // Frames per second to cache at +} + +// Shared singleton cache across hook instances (HMR-safe) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const __frameCacheGlobal: any = globalThis as any; +const __sharedFrameCache: Map<number, CachedFrame> = + __frameCacheGlobal.__sharedFrameCache ?? new Map<number, CachedFrame>(); +__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache; + +export function useFrameCache(options: FrameCacheOptions = {}) { + const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps + + const frameCacheRef = useRef(__sharedFrameCache); + + // Generate a hash of the timeline state that affects rendering + const getTimelineHash = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + sceneId?: string + ): string => { + // Get elements that are active at this time + const activeElements: Array<{ + id: string; + type: string; + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + mediaId?: string; + // Text-specific properties + content?: string; + fontSize?: number; + fontFamily?: string; + color?: string; + backgroundColor?: string; + x?: number; + y?: number; + rotation?: number; + opacity?: number; + }> = []; + + for (const track of tracks) { + if (track.muted) continue; + + for (const element of track.elements) { + // Check if element has hidden property (some elements might not have it) + const isHidden = "hidden" in element ? element.hidden : false; + if (isHidden) continue; + + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (time >= elementStart && time < elementEnd) { + if (element.type === "media") { + const mediaElement = element as MediaElement; + activeElements.push({ + id: element.id, + type: element.type, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + mediaId: mediaElement.mediaId, + }); + } else if (element.type === "text") { + const textElement = element as TextElement; + activeElements.push({ + id: element.id, + type: element.type, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + content: textElement.content, + fontSize: textElement.fontSize, + fontFamily: textElement.fontFamily, + color: textElement.color, + backgroundColor: textElement.backgroundColor, + x: textElement.x, + y: textElement.y, + rotation: textElement.rotation, + opacity: textElement.opacity, + }); + } + } + } + } + + // Include project settings that affect rendering + const projectState = { + backgroundColor: activeProject?.backgroundColor, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, + canvasSize: activeProject?.canvasSize, + }; + + const hash = { + activeElements, + projectState, + sceneId, + time: Math.floor(time * cacheResolution) / cacheResolution, + }; + return JSON.stringify(hash); + }, + [cacheResolution] + ); + + // Check if a frame is cached and valid + const isFrameCached = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + sceneId?: string + ): boolean => { + const frameKey = Math.floor(time * cacheResolution); + const cached = frameCacheRef.current.get(frameKey); + + if (!cached) return false; + + const currentHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject, + sceneId + ); + return cached.timelineHash === currentHash; + }, + [getTimelineHash, cacheResolution] + ); + + // Get cached frame if available and valid + const getCachedFrame = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + sceneId?: string + ): ImageData | null => { + const frameKey = Math.floor(time * cacheResolution); + const cached = frameCacheRef.current.get(frameKey); + + if (!cached) { + return null; + } + + const currentHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject, + sceneId + ); + console.log(cached.timelineHash === currentHash); + if (cached.timelineHash !== currentHash) { + // Cache is stale, remove it + console.log( + "Cache miss - hash mismatch:", + JSON.stringify({ + cachedHash: cached.timelineHash.slice(0, 100), + currentHash: currentHash.slice(0, 100), + }) + ); + frameCacheRef.current.delete(frameKey); + return null; + } + + return cached.imageData; + }, + [getTimelineHash, cacheResolution] + ); + + // Cache a rendered frame + const cacheFrame = useCallback( + ( + time: number, + imageData: ImageData, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + sceneId?: string + ): void => { + const frameKey = Math.floor(time * cacheResolution); + const timelineHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject, + sceneId + ); + + // Enforce cache size limit (LRU eviction) + if (frameCacheRef.current.size >= maxCacheSize) { + // Remove oldest entries + const entries = Array.from(frameCacheRef.current.entries()); + entries.sort((a, b) => a[1].timestamp - b[1].timestamp); + + // Remove oldest 20% of entries + const toRemove = Math.floor(entries.length * 0.2); + for (let i = 0; i < toRemove; i++) { + frameCacheRef.current.delete(entries[i][0]); + } + } + + frameCacheRef.current.set(frameKey, { + imageData, + timelineHash, + timestamp: Date.now(), + }); + }, + [getTimelineHash, cacheResolution, maxCacheSize] + ); + + // Clear cache when timeline changes significantly + const invalidateCache = useCallback(() => { + frameCacheRef.current.clear(); + }, []); + + // Get render status for timeline indicator + const getRenderStatus = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + sceneId?: string + ): "cached" | "not-cached" => { + return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId) + ? "cached" + : "not-cached"; + }, + [isFrameCached] + ); + + // Pre-render frames around current time + const preRenderNearbyFrames = useCallback( + async ( + currentTime: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + renderFunction: (time: number) => Promise<ImageData>, + sceneId?: string, + range: number = 3 // seconds + ) => { + const framesToPreRender: number[] = []; + + // Calculate frames to pre-render (around current time) + for ( + let offset = -range; + offset <= range; + offset += 1 / cacheResolution + ) { + const time = currentTime + offset; + if (time < 0) continue; + + if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) { + framesToPreRender.push(time); + } + } + + // Expand to full 1-second buckets to avoid fragmented tiny cache regions + const secondsToPreRender = new Set<number>(); + for (const t of framesToPreRender) { + secondsToPreRender.add(Math.floor(t)); + } + + const expandedTimes: number[] = []; + for (const s of secondsToPreRender) { + for (let k = 0; k < cacheResolution; k++) { + const t = s + k / cacheResolution; + if (t < 0) continue; + if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) { + expandedTimes.push(t); + } + } + } + + // Sort forward-first near currentTime to improve perceived responsiveness + expandedTimes.sort((a, b) => { + const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6; + const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6; + return da - db; + }); + + // Cap total scheduled renders to avoid jank (e.g., up to 90 frames) + const CAP = Math.max(30, Math.min(90, cacheResolution * 3)); + const toSchedule = expandedTimes.slice(0, CAP); + + // Pre-render during idle time + for (const time of toSchedule) { + requestIdleCallback(async () => { + try { + const imageData = await renderFunction(time); + cacheFrame( + time, + imageData, + tracks, + mediaFiles, + activeProject, + sceneId + ); + } catch (error) { + console.warn(`Pre-render failed for time ${time}:`, error); + } + }); + } + }, + [isFrameCached, cacheFrame, cacheResolution] + ); + + return { + isFrameCached, + getCachedFrame, + cacheFrame, + invalidateCache, + getRenderStatus, + preRenderNearbyFrames, + cacheSize: frameCacheRef.current.size, + }; +} diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts index ecbd1232..c27911d2 100644 --- a/apps/web/src/hooks/use-playback-controls.ts +++ b/apps/web/src/hooks/use-playback-controls.ts @@ -9,7 +9,7 @@ export const usePlaybackControls = () => { const { selectedElements, tracks, - splitElement, + splitSelected, splitAndKeepLeft, splitAndKeepRight, separateAudio, @@ -37,8 +37,8 @@ export const usePlaybackControls = () => { return; } - splitElement(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitElement]); + splitSelected(currentTime, trackId, elementId); + }, [selectedElements, tracks, currentTime, splitSelected]); const handleSplitAndKeepLeftCallback = useCallback(() => { if (selectedElements.length !== 1) { diff --git a/apps/web/src/lib/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts new file mode 100644 index 00000000..7d1cdc05 --- /dev/null +++ b/apps/web/src/lib/canvas-gradients.ts @@ -0,0 +1,219 @@ +function splitBackgroundLayers(input: string): string[] { + const layers: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < input.length; i += 1) { + const ch = input[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === "," && depth === 0) { + layers.push(input.slice(start, i).trim()); + start = i + 1; + } + } + layers.push(input.slice(start).trim()); + return layers; +} + +function parseColorStop(stop: string): { color: string; position?: number } { + const s = stop.trim(); + + // Handle functional colors like rgba(), rgb(), hsla(), hsl() + const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("]; + let color = ""; + let remaining = ""; + + for (const fn of colorFunctions) { + if (s.startsWith(fn)) { + let depth = 0; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") { + depth -= 1; + if (depth === 0) { + color = s.slice(0, i + 1); + remaining = s.slice(i + 1).trim(); + break; + } + } + } + break; + } + } + + if (!color) { + const parts = s.split(/\s+/); + color = parts[0] as string; + remaining = parts.slice(1).join(" "); + } + + // Convert transparent to transparent white for better blending + if (color === "transparent") { + color = "rgba(255, 255, 255, 0)"; + } + + // Parse position if present + let position: number | undefined; + if (remaining) { + const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/); + if (posMatch) { + position = parseFloat(posMatch[1] as string) / 100; + } + } + + return { color, position }; +} + +function parseLinearGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const dir = (parts.shift() || "").trim(); + + let x0 = 0, + y0 = 0, + x1 = width, + y1 = 0; // default: to right + + if (dir.endsWith("deg")) { + const deg = parseFloat(dir); + const rad = (deg * Math.PI) / 180; + const cx = width / 2; + const cy = height / 2; + const r = Math.hypot(width, height) / 2; + x0 = cx - Math.cos(rad) * r; + y0 = cy - Math.sin(rad) * r; + x1 = cx + Math.cos(rad) * r; + y1 = cy + Math.sin(rad) * r; + } else if (dir.startsWith("to ")) { + const d = dir.slice(3).trim(); + if (d === "right") { + x0 = 0; + y0 = 0; + x1 = width; + y1 = 0; + } else if (d === "left") { + x0 = width; + y0 = 0; + x1 = 0; + y1 = 0; + } else if (d === "bottom") { + x0 = 0; + y0 = 0; + x1 = 0; + y1 = height; + } else if (d === "top") { + x0 = 0; + y0 = height; + x1 = 0; + y1 = 0; + } + } else { + // No direction specified, treat as first color + parts.unshift(dir); + } + + return { x0, y0, x1, y1, colors: parts }; +} + +function parseRadialGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const first = (parts.shift() || "").trim(); + + let cx = width / 2; + let cy = height / 2; + + if (first.startsWith("circle at")) { + const pos = first.replace("circle at", "").trim(); + const coords = pos.split(/\s+/); + + for (let i = 0; i < coords.length; i += 1) { + const coord = coords[i] as string; + if (coord.endsWith("%")) { + const val = parseFloat(coord) / 100; + if (i === 0) cx = val * width; + else if (i === 1) cy = val * height; + } else if (coord === "left") cx = 0; + else if (coord === "right") cx = width; + else if (coord === "top") cy = 0; + else if (coord === "bottom") cy = height; + else if (coord === "center") { + if (i === 0) cx = width / 2; + else if (i === 1) cy = height / 2; + } + } + } else { + parts.unshift(first); + } + + // Use farthest-corner for radius + const r = Math.max( + Math.hypot(cx, cy), + Math.hypot(width - cx, cy), + Math.hypot(cx, height - cy), + Math.hypot(width - cx, height - cy) + ); + + return { cx, cy, r, colors: parts }; +} + +export function drawCssBackground( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + css: string +): void { + const layers = splitBackgroundLayers(css).filter(Boolean); + + // Draw layers from last to first (CSS background order) + for (let i = layers.length - 1; i >= 0; i -= 1) { + const layer = layers[i] as string; + + if (layer.startsWith("linear-gradient(")) { + const { x0, y0, x1, y1, colors } = parseLinearGradient( + layer, + width, + height + ); + const grad = ctx.createLinearGradient(x0, y0, x1, y1); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if (layer.startsWith("radial-gradient(")) { + const { cx, cy, r, colors } = parseRadialGradient(layer, width, height); + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if ( + layer.startsWith("#") || + layer.startsWith("rgb") || + layer.startsWith("hsl") || + layer === "transparent" || + layer === "white" || + layer === "black" + ) { + if (layer !== "transparent") { + ctx.fillStyle = layer; + ctx.fillRect(0, 0, width, height); + } + } + } +} diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index d2593879..52c58736 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -14,7 +14,7 @@ import { renderTimelineFrame } from "./timeline-renderer"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { useProjectStore } from "@/stores/project-store"; -import { DEFAULT_FPS } from "@/stores/project-store"; +import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/stores/project-store"; import { ExportOptions, ExportResult } from "@/types/export"; import { TimelineTrack } from "@/types/timeline"; import { MediaFile } from "@/types/media"; @@ -61,11 +61,12 @@ async function createTimelineAudioBuffer( for (const element of track.elements) { if (element.type !== "media") continue; - const mediaItem = element.type === "media" ? mediaMap.get(element.mediaId) : null; + const mediaElement = element; + const mediaItem = mediaMap.get(mediaElement.mediaId); if (!mediaItem || mediaItem.type !== "audio") continue; const visibleDuration = - element.duration - element.trimStart - element.trimEnd; + mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd; if (visibleDuration <= 0) continue; try { @@ -77,11 +78,11 @@ async function createTimelineAudioBuffer( audioElements.push({ buffer: audioBuffer, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - muted: element.muted || track.muted || false, + startTime: mediaElement.startTime, + duration: mediaElement.duration, + trimStart: mediaElement.trimStart, + trimEnd: mediaElement.trimEnd, + muted: mediaElement.muted || track.muted || false, }); } catch (error) { console.warn(`Failed to decode audio file ${mediaItem.name}:`, error); @@ -170,7 +171,7 @@ export async function exportProject( } const exportFps = fps || activeProject.fps || DEFAULT_FPS; - const canvasSize = activeProject.canvasSize; + const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE; const outputFormat = format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); @@ -251,9 +252,11 @@ export async function exportProject( canvasHeight: canvas.height, tracks, mediaFiles, + backgroundType: activeProject.backgroundType, + blurIntensity: activeProject.blurIntensity, backgroundColor: activeProject.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); diff --git a/apps/web/src/lib/fetch-github-stars.ts b/apps/web/src/lib/fetch-github-stars.ts deleted file mode 100644 index 0372f25b..00000000 --- a/apps/web/src/lib/fetch-github-stars.ts +++ /dev/null @@ -1,29 +0,0 @@ -export async function getStars(): Promise<string> { - try { - const res = await fetch( - "https://api.github.com/repos/OpenCut-app/OpenCut", - { - next: { revalidate: 3600 }, - } - ); - - if (!res.ok) { - throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); - } - const data = (await res.json()) as { stargazers_count: number }; - const count = data.stargazers_count; - - if (typeof count !== "number") { - throw new Error("Invalid stargazers_count from GitHub API"); - } - - if (count >= 1_000_000) - return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; - if (count >= 1000) - return (count / 1000).toFixed(1).replace(/\.0$/, "") + "k"; - return count.toString(); - } catch (error) { - console.error("Failed to fetch GitHub stars:", error); - return "1.5k"; - } -} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 3aadf883..2688f6e8 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -6,6 +6,7 @@ import { MediaFileData, StorageConfig, SerializedProject, + SerializedScene, TimelineData, } from "./types"; import { TimelineTrack } from "@/types/timeline"; @@ -39,7 +40,7 @@ class StorageService { } // Helper to get project-specific media adapters - private getProjectMediaAdapters(projectId: string) { + private getProjectMediaAdapters({ projectId }: { projectId: string }) { const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( `${this.config.mediaDb}-${projectId}`, "media-metadata", @@ -52,23 +53,43 @@ class StorageService { } // Helper to get project-specific timeline adapter - private getProjectTimelineAdapter(projectId: string) { + private getProjectTimelineAdapter({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) { + const dbName = sceneId + ? `${this.config.timelineDb}-${projectId}-${sceneId}` + : `${this.config.timelineDb}-${projectId}`; + return new IndexedDBAdapter<TimelineData>( - `${this.config.timelineDb}-${projectId}`, + dbName, "timeline", this.config.version ); } // Project operations - async saveProject(project: TProject): Promise<void> { + async saveProject({ project }: { project: TProject }): Promise<void> { // Convert TProject to serializable format + const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + })); + const serializedProject: SerializedProject = { id: project.id, name: project.name, thumbnail: project.thumbnail, createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), + scenes: serializedScenes, + currentSceneId: project.currentSceneId, backgroundColor: project.backgroundColor, backgroundType: project.backgroundType, blurIntensity: project.blurIntensity, @@ -81,18 +102,30 @@ class StorageService { await this.projectsAdapter.set(project.id, serializedProject); } - async loadProject(id: string): Promise<TProject | null> { + async loadProject({ id }: { id: string }): Promise<TProject | null> { const serializedProject = await this.projectsAdapter.get(id); if (!serializedProject) return null; + // Now convert serialized scenes back to Scene objects + const scenes = + serializedProject.scenes?.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + createdAt: new Date(scene.createdAt), + updatedAt: new Date(scene.updatedAt), + })) || []; + // Convert back to TProject format - return { + const project = { id: serializedProject.id, name: serializedProject.name, thumbnail: serializedProject.thumbnail, createdAt: new Date(serializedProject.createdAt), updatedAt: new Date(serializedProject.updatedAt), + scenes, + currentSceneId: serializedProject.currentSceneId || "", backgroundColor: serializedProject.backgroundColor, backgroundType: serializedProject.backgroundType, blurIntensity: serializedProject.blurIntensity, @@ -101,6 +134,7 @@ class StorageService { canvasSize: serializedProject.canvasSize, canvasMode: serializedProject.canvasMode, }; + return project; } async loadAllProjects(): Promise<TProject[]> { @@ -108,7 +142,7 @@ class StorageService { const projects: TProject[] = []; for (const id of projectIds) { - const project = await this.loadProject(id); + const project = await this.loadProject({ id }); if (project) { projects.push(project); } @@ -120,14 +154,20 @@ class StorageService { ); } - async deleteProject(id: string): Promise<void> { + async deleteProject({ id }: { id: string }): Promise<void> { await this.projectsAdapter.remove(id); } // Media operations - async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> { + async saveMediaFile({ + projectId, + mediaItem, + }: { + projectId: string; + mediaItem: MediaFile; + }): Promise<void> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); // Save file to project-specific OPFS await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); @@ -148,12 +188,15 @@ class StorageService { await mediaMetadataAdapter.set(mediaItem.id, metadata); } - async loadMediaFile( - projectId: string, - id: string - ): Promise<MediaFile | null> { + async loadMediaFile({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise<MediaFile | null> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); const [file, metadata] = await Promise.all([ mediaFilesAdapter.get(id), @@ -192,14 +235,20 @@ class StorageService { }; } - async loadAllMediaFiles(projectId: string): Promise<MediaFile[]> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + async loadAllMediaFiles({ + projectId, + }: { + projectId: string; + }): Promise<MediaFile[]> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); const mediaIds = await mediaMetadataAdapter.list(); const mediaItems: MediaFile[] = []; for (const id of mediaIds) { - const item = await this.loadMediaFile(projectId, id); + const item = await this.loadMediaFile({ projectId, id }); if (item) { mediaItems.push(item); } @@ -208,9 +257,15 @@ class StorageService { return mediaItems; } - async deleteMediaFile(projectId: string, id: string): Promise<void> { + async deleteMediaFile({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise<void> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaFilesAdapter.remove(id), @@ -218,9 +273,13 @@ class StorageService { ]); } - async deleteProjectMedia(projectId: string): Promise<void> { + async deleteProjectMedia({ + projectId, + }: { + projectId: string; + }): Promise<void> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaMetadataAdapter.clear(), @@ -228,12 +287,20 @@ class StorageService { ]); } - // Timeline operations - now project-specific - async saveTimeline( - projectId: string, - tracks: TimelineTrack[] - ): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + // Timeline operations - supports both legacy and scene-based storage + async saveTimeline({ + projectId, + tracks, + sceneId, + }: { + projectId: string; + tracks: TimelineTrack[]; + sceneId?: string; + }): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter({ + projectId, + sceneId, + }); const timelineData: TimelineData = { tracks, lastModified: new Date().toISOString(), @@ -241,14 +308,27 @@ class StorageService { await timelineAdapter.set("timeline", timelineData); } - async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + async loadTimeline({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }): Promise<TimelineTrack[] | null> { + const timelineAdapter = this.getProjectTimelineAdapter({ + projectId, + sceneId, + }); const timelineData = await timelineAdapter.get("timeline"); return timelineData ? timelineData.tracks : null; } - async deleteProjectTimeline(projectId: string): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + async deleteProjectTimeline({ + projectId, + }: { + projectId: string; + }): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter({ projectId }); await timelineAdapter.remove("timeline"); } @@ -274,12 +354,14 @@ class StorageService { }; } - async getProjectStorageInfo(projectId: string): Promise<{ + async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{ mediaItems: number; hasTimeline: boolean; }> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); + const timelineAdapter = this.getProjectTimelineAdapter({ projectId }); const [mediaIds, timelineData] = await Promise.all([ mediaMetadataAdapter.list(), @@ -307,7 +389,11 @@ class StorageService { } } - async saveSoundEffect(soundEffect: SoundEffect): Promise<void> { + async saveSoundEffect({ + soundEffect, + }: { + soundEffect: SoundEffect; + }): Promise<void> { try { const currentData = await this.loadSavedSounds(); @@ -340,7 +426,7 @@ class StorageService { } } - async removeSavedSound(soundId: number): Promise<void> { + async removeSavedSound({ soundId }: { soundId: number }): Promise<void> { try { const currentData = await this.loadSavedSounds(); @@ -356,7 +442,7 @@ class StorageService { } } - async isSoundSaved(soundId: number): Promise<boolean> { + async isSoundSaved({ soundId }: { soundId: number }): Promise<boolean> { try { const currentData = await this.loadSavedSounds(); return currentData.sounds.some((sound) => sound.id === soundId); diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index 8f593867..e66239cd 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -1,4 +1,4 @@ -import { TProject } from "@/types/project"; +import { TProject, Scene } from "@/types/project"; import { TimelineTrack } from "@/types/timeline"; export interface StorageAdapter<T> { @@ -23,11 +23,23 @@ export interface MediaFileData { // File will be stored separately in OPFS } +// Legacy timeline data, kept for backward compatibility export interface TimelineData { tracks: TimelineTrack[]; lastModified: string; } +export interface SceneTimelineData { + sceneId: string; + tracks: TimelineTrack[]; + lastModified: string; +} + +export type SerializedScene = Omit<Scene, "createdAt" | "updatedAt"> & { + createdAt: string; + updatedAt: string; +}; + export interface StorageConfig { projectsDb: string; mediaDb: string; @@ -37,9 +49,13 @@ export interface StorageConfig { } // Helper type for serialization - converts Date objects to strings -export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & { +export type SerializedProject = Omit< + TProject, + "createdAt" | "updatedAt" | "scenes" +> & { createdAt: string; updatedAt: string; + scenes: SerializedScene[]; bookmarks?: number[]; }; diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 6241a7d9..b2650b7c 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,6 +1,8 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; import type { TimelineTrack } from "@/types/timeline"; import type { MediaFile } from "@/types/media"; +import type { BlurIntensity } from "@/types/project"; +import { videoCache } from "./video-cache"; +import { drawCssBackground } from "./canvas-gradients"; export interface RenderContext { ctx: CanvasRenderingContext2D; @@ -10,9 +12,29 @@ export interface RenderContext { tracks: TimelineTrack[]; mediaFiles: MediaFile[]; backgroundColor?: string; + backgroundType?: "color" | "blur"; + blurIntensity?: BlurIntensity; projectCanvasSize?: { width: number; height: number }; } +const imageElementCache = new Map<string, HTMLImageElement>(); + +async function getImageElement( + mediaItem: MediaFile +): Promise<HTMLImageElement> { + const cacheKey = mediaItem.id; + const cached = imageElementCache.get(cacheKey); + if (cached) return cached; + const img = new Image(); + await new Promise<void>((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Image load failed")); + img.src = mediaItem.url || URL.createObjectURL(mediaItem.file); + }); + imageElementCache.set(cacheKey, img); + return img; +} + export async function renderTimelineFrame({ ctx, time, @@ -21,15 +43,26 @@ export async function renderTimelineFrame({ tracks, mediaFiles, backgroundColor, + backgroundType, + blurIntensity, projectCanvasSize, }: RenderContext): Promise<void> { // Background ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { + if ( + backgroundColor && + backgroundColor !== "transparent" && + !backgroundColor.includes("gradient") + ) { ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvasWidth, canvasHeight); } + // If backgroundColor is a CSS gradient string, draw it + if (backgroundColor && backgroundColor.includes("gradient")) { + drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); + } + const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; const scaleY = projectCanvasSize ? canvasHeight / projectCanvasSize.height @@ -44,7 +77,7 @@ export async function renderTimelineFrame({ for (let t = tracks.length - 1; t >= 0; t -= 1) { const track = tracks[t]; for (const element of track.elements) { - if ((element as any).hidden) continue; + if (element.hidden) continue; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -62,34 +95,103 @@ export async function renderTimelineFrame({ } } + // If background is set to blur, draw the active media as a blurred cover layer first + if (backgroundType === "blur") { + const blurPx = Math.max(0, blurIntensity ?? 8); + // Find a suitable media element (video/image) among active elements + const bgCandidate = active.find(({ element, mediaItem }) => { + return ( + element.type === "media" && + mediaItem !== null && + (mediaItem.type === "video" || mediaItem.type === "image") + ); + }); + if (bgCandidate && bgCandidate.mediaItem) { + const { element, mediaItem } = bgCandidate; + try { + if (mediaItem.type === "video") { + const localTime = time - element.startTime + element.trimStart; + const frame = await videoCache.getFrameAt( + mediaItem.id, + mediaItem.file, + Math.max(0, localTime) + ); + if (frame) { + const mediaW = Math.max(1, mediaItem.width || canvasWidth); + const mediaH = Math.max(1, mediaItem.height || canvasHeight); + const coverScale = Math.max( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * coverScale; + const drawH = mediaH * coverScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + ctx.save(); + ctx.filter = `blur(${blurPx}px)`; + ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + ctx.restore(); + } + } else if (mediaItem.type === "image") { + const img = await getImageElement(mediaItem); + const mediaW = Math.max( + 1, + mediaItem.width || img.naturalWidth || canvasWidth + ); + const mediaH = Math.max( + 1, + mediaItem.height || img.naturalHeight || canvasHeight + ); + const coverScale = Math.max( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * coverScale; + const drawH = mediaH * coverScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + ctx.save(); + ctx.filter = `blur(${blurPx}px)`; + ctx.drawImage(img, drawX, drawY, drawW, drawH); + ctx.restore(); + } + } catch { + // Ignore background blur failures; foreground will still render + } + } + } + for (const { element, mediaItem } of active) { if (element.type === "media" && mediaItem) { if (mediaItem.type === "video") { - const input = new Input({ - source: new BlobSource(mediaItem.file), - formats: ALL_FORMATS, - }); - const track = await input.getPrimaryVideoTrack(); - if (!track) continue; - const decodable = await track.canDecode(); - if (!decodable) continue; - const sink = new VideoSampleSink(track); + try { + const localTime = time - element.startTime + element.trimStart; - const localTime = time - element.startTime + element.trimStart; - const sample = await sink.getSample(localTime); - if (!sample) continue; + const frame = await videoCache.getFrameAt( + mediaItem.id, + mediaItem.file, + localTime + ); + if (!frame) continue; - const mediaW = Math.max(1, mediaItem.width || canvasWidth); - const mediaH = Math.max(1, mediaItem.height || canvasHeight); - const containScale = Math.min( - canvasWidth / mediaW, - canvasHeight / mediaH - ); - const drawW = mediaW * containScale; - const drawH = mediaH * containScale; - const drawX = (canvasWidth - drawW) / 2; - const drawY = (canvasHeight - drawH) / 2; - sample.draw(ctx, drawX, drawY, drawW, drawH); + const mediaW = Math.max(1, mediaItem.width || canvasWidth); + const mediaH = Math.max(1, mediaItem.height || canvasHeight); + const containScale = Math.min( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * containScale; + const drawH = mediaH * containScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + + ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + } catch (error) { + console.warn( + `Failed to render video frame for ${mediaItem.name}:`, + error + ); + } } if (mediaItem.type === "image") { const img = new Image(); @@ -118,33 +220,47 @@ export async function renderTimelineFrame({ } } if (element.type === "text") { - const posX = canvasWidth / 2 + (element as any).x * scaleX; - const posY = canvasHeight / 2 + (element as any).y * scaleY; + const text = element; + const posX = canvasWidth / 2 + text.x * scaleX; + const posY = canvasHeight / 2 + text.y * scaleY; ctx.save(); ctx.translate(posX, posY); - ctx.rotate(((element as any).rotation * Math.PI) / 180); - ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity)); - const px = (element as any).fontSize * scaleX; - const weight = (element as any).fontWeight === "bold" ? "bold " : ""; - const style = (element as any).fontStyle === "italic" ? "italic " : ""; - ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`; - ctx.fillStyle = (element as any).color; - ctx.textAlign = (element as any).textAlign; + ctx.rotate((text.rotation * Math.PI) / 180); + ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity)); + const px = text.fontSize * scaleX; + const weight = text.fontWeight === "bold" ? "bold " : ""; + const style = text.fontStyle === "italic" ? "italic " : ""; + ctx.font = `${style}${weight}${px}px ${text.fontFamily}`; + ctx.fillStyle = text.color; + ctx.textAlign = text.textAlign as CanvasTextAlign; ctx.textBaseline = "middle"; - const metrics = ctx.measureText((element as any).content); - const ascent = - (metrics as unknown as { actualBoundingBoxAscent?: number }) - .actualBoundingBoxAscent ?? px * 0.8; - const descent = - (metrics as unknown as { actualBoundingBoxDescent?: number }) - .actualBoundingBoxDescent ?? px * 0.2; + const metrics = ctx.measureText(text.content); + const hasBoxMetrics = + "actualBoundingBoxAscent" in metrics && + "actualBoundingBoxDescent" in metrics; + const ascent = hasBoxMetrics + ? ( + metrics as TextMetrics & { + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + } + ).actualBoundingBoxAscent + : px * 0.8; + const descent = hasBoxMetrics + ? ( + metrics as TextMetrics & { + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + } + ).actualBoundingBoxDescent + : px * 0.2; const textW = metrics.width; const textH = ascent + descent; const padX = 8 * scaleX; const padY = 4 * scaleX; - if ((element as any).backgroundColor) { + if (text.backgroundColor) { ctx.save(); - ctx.fillStyle = (element as any).backgroundColor; + ctx.fillStyle = text.backgroundColor; let bgLeft = -textW / 2; if (ctx.textAlign === "left") bgLeft = 0; if (ctx.textAlign === "right") bgLeft = -textW; @@ -156,7 +272,7 @@ export async function renderTimelineFrame({ ); ctx.restore(); } - ctx.fillText((element as any).content, 0, 0); + ctx.fillText(text.content, 0, 0); ctx.restore(); } } diff --git a/apps/web/src/lib/video-cache.ts b/apps/web/src/lib/video-cache.ts new file mode 100644 index 00000000..aeb62d1c --- /dev/null +++ b/apps/web/src/lib/video-cache.ts @@ -0,0 +1,186 @@ +import {
+ Input,
+ ALL_FORMATS,
+ BlobSource,
+ CanvasSink,
+ WrappedCanvas,
+} from "mediabunny";
+
+interface VideoSinkData {
+ sink: CanvasSink;
+ iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
+ currentFrame: WrappedCanvas | null;
+ lastTime: number;
+}
+export class VideoCache {
+ private sinks = new Map<string, VideoSinkData>();
+ private initPromises = new Map<string, Promise<void>>();
+
+ async getFrameAt(
+ mediaId: string,
+ file: File,
+ time: number
+ ): Promise<WrappedCanvas | null> {
+ await this.ensureSink(mediaId, file);
+
+ const sinkData = this.sinks.get(mediaId);
+ if (!sinkData) return null;
+
+ if (
+ sinkData.currentFrame &&
+ this.isFrameValid(sinkData.currentFrame, time)
+ ) {
+ return sinkData.currentFrame;
+ }
+
+ if (
+ sinkData.iterator &&
+ sinkData.currentFrame &&
+ time >= sinkData.lastTime &&
+ time < sinkData.lastTime + 2.0
+ ) {
+ const frame = await this.iterateToTime(sinkData, time);
+ if (frame) return frame;
+ }
+
+ return await this.seekToTime(sinkData, time);
+ }
+
+ private isFrameValid(frame: WrappedCanvas, time: number): boolean {
+ return time >= frame.timestamp && time < frame.timestamp + frame.duration;
+ }
+ private async iterateToTime(
+ sinkData: VideoSinkData,
+ targetTime: number
+ ): Promise<WrappedCanvas | null> {
+ if (!sinkData.iterator) return null;
+
+ try {
+ while (true) {
+ const { value: frame, done } = await sinkData.iterator.next();
+
+ if (done || !frame) break;
+
+ sinkData.currentFrame = frame;
+ sinkData.lastTime = frame.timestamp;
+
+ if (this.isFrameValid(frame, targetTime)) {
+ return frame;
+ }
+
+ if (frame.timestamp > targetTime + 1.0) break;
+ }
+ } catch (error) {
+ console.warn("Iterator failed, will restart:", error);
+ sinkData.iterator = null;
+ }
+
+ return null;
+ }
+ private async seekToTime(
+ sinkData: VideoSinkData,
+ time: number
+ ): Promise<WrappedCanvas | null> {
+ try {
+ if (sinkData.iterator) {
+ await sinkData.iterator.return();
+ sinkData.iterator = null;
+ }
+
+ sinkData.iterator = sinkData.sink.canvases(time);
+ sinkData.lastTime = time;
+
+ const { value: frame } = await sinkData.iterator.next();
+
+ if (frame) {
+ sinkData.currentFrame = frame;
+ return frame;
+ }
+ } catch (error) {
+ console.warn("Failed to seek video:", error);
+ }
+
+ return null;
+ }
+ private async ensureSink(mediaId: string, file: File): Promise<void> {
+ if (this.sinks.has(mediaId)) return;
+
+ if (this.initPromises.has(mediaId)) {
+ await this.initPromises.get(mediaId);
+ return;
+ }
+
+ const initPromise = this.initializeSink(mediaId, file);
+ this.initPromises.set(mediaId, initPromise);
+
+ try {
+ await initPromise;
+ } finally {
+ this.initPromises.delete(mediaId);
+ }
+ }
+ private async initializeSink(mediaId: string, file: File): Promise<void> {
+ try {
+ const input = new Input({
+ source: new BlobSource(file),
+ formats: ALL_FORMATS,
+ });
+
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack) {
+ throw new Error("No video track found");
+ }
+
+ const canDecode = await videoTrack.canDecode();
+ if (!canDecode) {
+ throw new Error("Video codec not supported for decoding");
+ }
+
+ const sink = new CanvasSink(videoTrack, {
+ poolSize: 3,
+ fit: "contain",
+ });
+
+ this.sinks.set(mediaId, {
+ sink,
+ iterator: null,
+ currentFrame: null,
+ lastTime: -1,
+ });
+ } catch (error) {
+ console.error(`Failed to initialize video sink for ${mediaId}:`, error);
+ throw error;
+ }
+ }
+
+ clearVideo(mediaId: string): void {
+ const sinkData = this.sinks.get(mediaId);
+ if (sinkData) {
+ if (sinkData.iterator) {
+ sinkData.iterator.return();
+ }
+
+ this.sinks.delete(mediaId);
+ }
+
+ this.initPromises.delete(mediaId);
+ }
+
+ clearAll(): void {
+ for (const [mediaId] of this.sinks) {
+ this.clearVideo(mediaId);
+ }
+ }
+
+ getStats() {
+ return {
+ totalSinks: this.sinks.size,
+ activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator)
+ .length,
+ cachedFrames: Array.from(this.sinks.values()).filter(
+ (s) => s.currentFrame
+ ).length,
+ };
+ }
+}
+export const videoCache = new VideoCache();
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 7fa0da5e..febcaa30 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -206,6 +206,9 @@ function getPressedKey(ev: KeyboardEvent): string | null { const key = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; + // Check arrow keys if (key.startsWith("arrow")) { return key.slice(5); @@ -213,7 +216,6 @@ function getPressedKey(ev: KeyboardEvent): string | null { // Check for special keys if (key === "tab") return "tab"; - if (key === " " || key === "space") return "space"; if (key === "home") return "home"; if (key === "end") return "end"; if (key === "delete") return "delete"; diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 2b7cb62e..61c771db 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -3,6 +3,7 @@ import { storageService } from "@/lib/storage/storage-service"; import { useTimelineStore } from "./timeline-store"; import { generateUUID } from "@/lib/utils"; import { MediaType, MediaFile } from "@/types/media"; +import { videoCache } from "@/lib/video-cache"; interface MediaStore { mediaFiles: MediaFile[]; @@ -151,7 +152,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // Save to persistent storage in background try { - await storageService.saveMediaFile(projectId, newItem); + await storageService.saveMediaFile({ projectId, mediaItem: newItem }); } catch (error) { console.error("Failed to save media item:", error); // Remove from local state if save failed @@ -165,6 +166,8 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ const state = get(); const item = state.mediaFiles.find((media) => media.id === id); + videoCache.clearVideo(id); + // Cleanup object URLs to prevent memory leaks if (item?.url) { URL.revokeObjectURL(item.url); @@ -200,6 +203,12 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // If there are elements to remove, push history once before batch removal if (elementsToRemove.length > 0) { + const { + removeElementFromTrack, + removeElementFromTrackWithRipple, + rippleEditingEnabled, + pushHistory, + } = useTimelineStore.getState(); pushHistory(); // Remove all elements without pushing additional history entries @@ -214,7 +223,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // 3) Remove from persistent storage try { - await storageService.deleteMediaFile(projectId, id); + await storageService.deleteMediaFile({ projectId, id }); } catch (error) { console.error("Failed to delete media item:", error); } @@ -224,7 +233,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ set({ isLoading: true }); try { - const mediaItems = await storageService.loadAllMediaFiles(projectId); + const mediaItems = await storageService.loadAllMediaFiles({ projectId }); // Regenerate thumbnails for video items const updatedMediaItems = await Promise.all( @@ -279,7 +288,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ try { const mediaIds = state.mediaFiles.map((item) => item.id); await Promise.all( - mediaIds.map((id) => storageService.deleteMediaFile(projectId, id)) + mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })) ); } catch (error) { console.error("Failed to clear media items from storage:", error); @@ -289,6 +298,8 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ clearAllMedia: () => { const state = get(); + videoCache.clearAll(); + // Cleanup all object URLs state.mediaFiles.forEach((item) => { if (item.url) { diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index b924ea22..c58f7309 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,28 +1,45 @@ -import { TProject } from "@/types/project"; +import { TProject, BlurIntensity, Scene } from "@/types/project"; import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { toast } from "sonner"; import { useMediaStore } from "./media-store"; import { useTimelineStore } from "./timeline-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { CanvasSize, CanvasMode } from "@/types/editor"; export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 }; export const DEFAULT_FPS = 30; -const DEFAULT_PROJECT: TProject = { - id: generateUUID(), - name: "Untitled", - thumbnail: "", - createdAt: new Date(), - updatedAt: new Date(), - backgroundColor: "#000000", - backgroundType: "color", - blurIntensity: 8, - bookmarks: [], - fps: DEFAULT_FPS, - canvasSize: DEFAULT_CANVAS_SIZE, - canvasMode: "preset", +export function createMainScene(): Scene { + return { + id: generateUUID(), + name: "Main Scene", + isMain: true, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +const createDefaultProject = (name: string): TProject => { + const mainScene = createMainScene(); + + return { + id: generateUUID(), + name, + thumbnail: "", + createdAt: new Date(), + updatedAt: new Date(), + scenes: [mainScene], + currentSceneId: mainScene.id, + backgroundColor: "#000000", + backgroundType: "color", + blurIntensity: 8, + bookmarks: [], + fps: DEFAULT_FPS, + canvasSize: DEFAULT_CANVAS_SIZE, + canvasMode: "preset", + }; }; interface ProjectStore { @@ -44,7 +61,7 @@ interface ProjectStore { updateProjectBackground: (backgroundColor: string) => Promise<void>; updateBackgroundType: ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => Promise<void>; updateProjectFps: (fps: number) => Promise<void>; updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>; @@ -104,7 +121,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -152,7 +169,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -164,23 +181,24 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }, createNewProject: async (name: string) => { - const newProject: TProject = { - ...DEFAULT_PROJECT, - id: generateUUID(), - name, - createdAt: new Date(), - updatedAt: new Date(), - }; + const newProject = createDefaultProject(name); set({ activeProject: newProject }); const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.initializeScenes({ + scenes: newProject.scenes, + currentSceneId: newProject.currentSceneId, + }); + try { - await storageService.saveProject(newProject); + await storageService.saveProject({ project: newProject }); // Reload all projects to update the list await get().loadAllProjects(); return newProject.id; @@ -195,21 +213,39 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ set({ isLoading: true }); } - // Clear media and timeline immediately to prevent flickering when switching projects + // Prevent flicker when switching projects - clear all stores const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); try { - const project = await storageService.loadProject(id); + const project = await storageService.loadProject({ id }); if (project) { set({ activeProject: project }); - // Load project-specific data in parallel + let currentScene = null; + if (project.scenes && project.scenes.length > 0) { + sceneStore.initializeScenes({ + scenes: project.scenes, + currentSceneId: project.currentSceneId, + }); + // Get current scene directly from project data (don't rely on store state) + currentScene = + project.scenes.find((s) => s.id === project.currentSceneId) || + project.scenes.find((s) => s.isMain) || + project.scenes[0]; + } + await Promise.all([ mediaStore.loadProjectMedia(id), - timelineStore.loadProjectTimeline(id), + timelineStore.loadProjectTimeline({ + projectId: id, + sceneId: currentScene?.id, + }), ]); } else { throw new Error(`Project with id ${id} not found`); @@ -227,11 +263,16 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ if (!activeProject) return; try { - // Save project metadata and timeline data in parallel const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + const currentScene = sceneStore.currentScene; + await Promise.all([ - storageService.saveProject(activeProject), - timelineStore.saveProjectTimeline(activeProject.id), + storageService.saveProject({ project: activeProject }), + timelineStore.saveProjectTimeline({ + projectId: activeProject.id, + sceneId: currentScene?.id, + }), ]); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -256,22 +297,24 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ deleteProject: async (id: string) => { try { - // Delete project data in parallel await Promise.all([ - storageService.deleteProjectMedia(id), - storageService.deleteProjectTimeline(id), - storageService.deleteProject(id), + storageService.deleteProjectMedia({ projectId: id }), + storageService.deleteProjectTimeline({ projectId: id }), + storageService.deleteProject({ id }), ]); await get().loadAllProjects(); // Refresh the list - // If we deleted the active project, close it and clear data + // If deleted active project, close it and clear data const { activeProject } = get(); if (activeProject?.id === id) { set({ activeProject: null }); const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); } } catch (error) { console.error("Failed to delete project:", error); @@ -281,11 +324,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ closeProject: () => { set({ activeProject: null }); - // Clear data from stores when closing project const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); }, renameProject: async (id: string, name: string) => { @@ -307,12 +352,11 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - // Save to storage - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); await get().loadAllProjects(); - // Update activeProject if it's the same project + // Update activeProject if same project const { activeProject } = get(); if (activeProject?.id === id) { set({ activeProject: updatedProject }); @@ -328,7 +372,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ duplicateProject: async (projectId: string) => { try { - const project = await storageService.loadProject(projectId); + const project = await storageService.loadProject({ id: projectId }); if (!project) { toast.error("Project not found", { description: "Please try again", @@ -355,14 +399,14 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; const newProject: TProject = { - ...project, // Copy all properties from the original project + ...project, id: generateUUID(), name: `(${nextNumber}) ${baseName}`, createdAt: new Date(), updatedAt: new Date(), }; - await storageService.saveProject(newProject); + await storageService.saveProject({ project: newProject }); await get().loadAllProjects(); return newProject.id; } catch (error) { @@ -386,9 +430,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update project background:", error); toast.error("Failed to update background", { @@ -399,7 +443,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ updateBackgroundType: async ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => { const { activeProject } = get(); if (!activeProject) return; @@ -410,14 +454,16 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ ...(options?.backgroundColor && { backgroundColor: options.backgroundColor, }), - ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }), + ...(options?.blurIntensity !== undefined && { + blurIntensity: options.blurIntensity, + }), updatedAt: new Date(), }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update background type:", error); toast.error("Failed to update background", { @@ -437,9 +483,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update project FPS:", error); toast.error("Failed to update project FPS", { @@ -460,9 +506,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update canvas size:", error); toast.error("Failed to update canvas size", { @@ -474,12 +520,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => { const { savedProjects } = get(); - // Filter projects by search query const filteredProjects = savedProjects.filter((project) => project.name.toLowerCase().includes(searchQuery.toLowerCase()) ); - // Sort filtered projects const sortedProjects = [...filteredProjects].sort((a, b) => { const [key, order] = sortOption.split("-"); @@ -506,7 +550,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ return sortedProjects; }, - // Global invalid project ID tracking implementation + // Global invalid project ID tracking isInvalidProjectId: (id: string) => { const invalidIds = get().invalidProjectIds || new Set(); return invalidIds.has(id); diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts new file mode 100644 index 00000000..a9f65a49 --- /dev/null +++ b/apps/web/src/stores/scene-store.ts @@ -0,0 +1,318 @@ +import { create } from "zustand"; +import { Scene } from "@/types/project"; +import { useProjectStore } from "./project-store"; +import { useTimelineStore } from "./timeline-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { generateUUID } from "@/lib/utils"; + +export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null { + return scenes.find((scene) => scene.isMain) || null; +} + +function ensureMainScene(scenes: Scene[]): Scene[] { + const hasMain = scenes.some((scene) => scene.isMain); + if (!hasMain) { + const mainScene: Scene = { + id: generateUUID(), + name: "Main scene", + isMain: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + return [mainScene, ...scenes]; + } + return scenes; +} + +interface SceneStore { + // Current scene state + currentScene: Scene | null; + scenes: Scene[]; + + // Scene management + createScene: ({ + name, + isMain, + }: { + name: string; + isMain: boolean; + }) => Promise<string>; + deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>; + renameScene: ({ + sceneId, + name, + }: { + sceneId: string; + name: string; + }) => Promise<void>; + switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>; + + // Scene utilities + getMainScene: () => Scene | null; + getCurrentScene: () => Scene | null; + + // Project integration + loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>; + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => void; + clearScenes: () => void; +} + +export const useSceneStore = create<SceneStore>((set, get) => ({ + currentScene: null, + scenes: [], + + createScene: async ({ name, isMain = false }) => { + const { scenes } = get(); + + const newScene = { + id: generateUUID(), + name, + isMain, + isBackground: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + const updatedScenes = [...scenes, newScene]; + + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ scenes: updatedScenes }); + return newScene.id; + } catch (error) { + console.error("Failed to create scene:", error); + throw error; + } + }, + + deleteScene: async ({ sceneId }: { sceneId: string }) => { + const { scenes, currentScene } = get(); + const sceneToDelete = scenes.find((s) => s.id === sceneId); + + if (!sceneToDelete) { + throw new Error("Scene not found"); + } + + if (sceneToDelete.isMain) { + throw new Error("Cannot delete main scene"); + } + + const updatedScenes = scenes.filter((s) => s.id !== sceneId); + + // Determine new current scene if we're deleting the current one + let newCurrentScene = currentScene; + if (currentScene?.id === sceneId) { + newCurrentScene = getMainScene({ scenes: updatedScenes }); + } + + // Update project + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ + scenes: updatedScenes, + currentScene: newCurrentScene, + }); + + // If we switched scenes, load the new scene's timeline + if (newCurrentScene && newCurrentScene.id !== currentScene?.id) { + const timelineStore = useTimelineStore.getState(); + await timelineStore.loadProjectTimeline({ + projectId: activeProject.id, + sceneId: newCurrentScene.id, + }); + } + } catch (error) { + console.error("Failed to delete scene:", error); + throw error; + } + }, + + renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { + const { scenes } = get(); + const updatedScenes = scenes.map((scene) => + scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene + ); + + // Update project + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ + scenes: updatedScenes, + currentScene: updatedScenes.find((s) => s.id === sceneId) || null, + }); + } catch (error) { + console.error("Failed to rename scene:", error); + throw error; + } + }, + + switchToScene: async ({ sceneId }: { sceneId: string }) => { + const { scenes } = get(); + const targetScene = scenes.find((s) => s.id === sceneId); + + if (!targetScene) { + throw new Error("Scene not found"); + } + + const timelineStore = useTimelineStore.getState(); + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + const { currentScene } = get(); + + if (activeProject && currentScene) { + await timelineStore.saveProjectTimeline({ + projectId: activeProject.id, + sceneId: currentScene.id, + }); + } + + if (activeProject) { + await timelineStore.loadProjectTimeline({ + projectId: activeProject.id, + sceneId, + }); + + const updatedProject = { + ...activeProject, + currentSceneId: sceneId, + updatedAt: new Date(), + }; + + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + } + + set({ currentScene: targetScene }); + }, + + getMainScene: () => { + const { scenes } = get(); + return scenes.find((scene) => scene.isMain) || null; + }, + + getCurrentScene: () => { + return get().currentScene; + }, + + loadProjectScenes: async ({ projectId }: { projectId: string }) => { + try { + const project = await storageService.loadProject({ id: projectId }); + if (project?.scenes) { + const ensuredScenes = project.scenes.map((scene) => ({ + ...scene, + isMain: scene.isMain || false, + })); + const currentScene = + ensuredScenes.find((s) => s.id === project.currentSceneId) || + ensuredScenes[0]; + + set({ + scenes: ensuredScenes, + currentScene, + }); + } + } catch (error) { + console.error("Failed to load project scenes:", error); + set({ scenes: [], currentScene: null }); + } + }, + + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => { + const ensuredScenes = ensureMainScene(scenes); + const currentScene = currentSceneId + ? ensuredScenes.find((s) => s.id === currentSceneId) + : null; + + const fallbackScene = getMainScene({ scenes: ensuredScenes }); + + set({ + scenes: ensuredScenes, + currentScene: currentScene || fallbackScene, + }); + + if (ensuredScenes.length > scenes.length) { + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (activeProject) { + const updatedProject = { + ...activeProject, + scenes: ensuredScenes, + updatedAt: new Date(), + }; + + storageService + .saveProject({ project: updatedProject }) + .then(() => { + useProjectStore.setState({ activeProject: updatedProject }); + }) + .catch((error) => { + console.error( + "Failed to save project with background scene:", + error + ); + }); + } + } + }, + + clearScenes: () => { + set({ + scenes: [], + currentScene: null, + }); + }, +})); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 6dffa198..579eee7a 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -162,7 +162,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({ saveSoundEffect: async (soundEffect: SoundEffect) => {
try {
- await storageService.saveSoundEffect(soundEffect);
+ await storageService.saveSoundEffect({ soundEffect });
// Refresh saved sounds
const savedSoundsData = await storageService.loadSavedSounds();
@@ -178,7 +178,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({ removeSavedSound: async (soundId: number) => {
try {
- await storageService.removeSavedSound(soundId);
+ await storageService.removeSavedSound({ soundId });
// Update local state immediately
set((state) => ({
diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts index da8b926b..c7c9ef98 100644 --- a/apps/web/src/stores/stickers-store.ts +++ b/apps/web/src/stores/stickers-store.ts @@ -9,6 +9,12 @@ import { type CollectionInfo, type IconSearchResult, } from "@/lib/iconify-api"; +import { useProjectStore } from "@/stores/project-store"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { MediaFile } from "@/types/media"; export type StickerCategory = "all" | "general" | "brands" | "emoji"; @@ -26,6 +32,7 @@ interface StickersStore { isLoadingCollection: boolean; isSearching: boolean; isDownloading: boolean; + addingSticker: string | null; setSearchQuery: (query: string) => void; setSelectedCategory: (category: StickerCategory) => void; @@ -36,6 +43,7 @@ interface StickersStore { loadCollection: (prefix: string) => Promise<void>; searchStickers: (query: string) => Promise<void>; downloadSticker: (iconName: string) => Promise<File | null>; + addStickerToTimeline: (iconName: string) => Promise<void>; addToRecentStickers: (iconName: string) => void; clearRecentStickers: () => void; @@ -58,6 +66,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({ isLoadingCollection: false, isSearching: false, isDownloading: false, + addingSticker: null, setSearchQuery: (query) => set({ searchQuery: query }), @@ -162,6 +171,50 @@ export const useStickersStore = create<StickersStore>((set, get) => ({ } }, + addStickerToTimeline: async (iconName: string) => { + set({ addingSticker: iconName }); + try { + const { activeProject } = useProjectStore.getState(); + if (!activeProject) { + throw new Error("No active project"); + } + + const file = await get().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, + }; + + const { addMediaFile } = useMediaStore.getState(); + 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"); + } + + const { currentTime } = usePlaybackStore.getState(); + const { addElementAtTime } = useTimelineStore.getState(); + addElementAtTime(added, currentTime); + } finally { + set({ addingSticker: null }); + } + }, + addToRecentStickers: (iconName: string) => { set((state) => { const recent = [ diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f0bae00c..aeeb65c5 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -6,19 +6,22 @@ import { TimelineTrack, TextElement, DragData, + MediaElement, sortTracksByOrder, ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; import { useMediaStore, getMediaAspectRatio } from "./media-store"; -import { MediaFile } from "@/types/media"; +import { MediaFile, MediaType } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { usePlaybackStore } from "./playback-store"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -98,11 +101,7 @@ interface TimelineStore { removeTrack: (trackId: string) => void; removeTrackWithRipple: (trackId: string) => void; addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; - removeElementFromTrack: ( - trackId: string, - elementId: string, - pushHistory?: boolean - ) => void; + moveElementToTrack: ( fromTrackId: string, toTrackId: string, @@ -128,15 +127,6 @@ interface TimelineStore { pushHistory?: boolean ) => void; toggleTrackMute: (trackId: string) => void; - toggleElementHidden: (trackId: string, elementId: string) => void; - toggleElementMuted: (trackId: string, elementId: string) => void; - - // Split operations for elements - splitElement: ( - trackId: string, - elementId: string, - splitTime: number - ) => string | null; splitAndKeepLeft: ( trackId: string, elementId: string, @@ -178,13 +168,52 @@ interface TimelineStore { pushHistory: () => void; // Persistence actions - loadProjectTimeline: (projectId: string) => Promise<void>; - saveProjectTimeline: (projectId: string) => Promise<void>; + loadProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise<void>; + saveProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise<void>; clearTimeline: () => void; // Clipboard actions copySelected: () => void; pasteAtTime: (time: number) => void; + + // Unified selection-aware actions + deleteSelected: (trackId?: string, elementId?: string) => void; + splitSelected: ( + splitTime: number, + trackId?: string, + elementId?: string + ) => void; + toggleSelectedHidden: (trackId?: string, elementId?: string) => void; + toggleSelectedMuted: (trackId?: string, elementId?: string) => void; + duplicateElement: (trackId: string, elementId: string) => void; + revealElementInMedia: (elementId: string) => void; + replaceElementWithFile: ( + trackId: string, + elementId: string, + file: File + ) => Promise<void>; + getContextMenuState: ( + trackId: string, + elementId: string + ) => { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + hasAudioElements: boolean; + canSplitSelected: boolean; + currentTime: number; + }; updateTextElement: ( trackId: string, elementId: string, @@ -235,12 +264,27 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { // Helper to auto-save timeline changes const autoSaveTimeline = async () => { const activeProject = useProjectStore.getState().activeProject; - if (activeProject) { + const currentScene = useSceneStore.getState().currentScene; + + if (activeProject && currentScene) { try { - await storageService.saveTimeline(activeProject.id, get()._tracks); + await storageService.saveTimeline({ + projectId: activeProject.id, + tracks: get()._tracks, + sceneId: currentScene.id, + }); } catch (error) { console.error("Failed to auto-save timeline:", error); } + } else { + console.warn( + "Auto-save skipped - missing activeProject or currentScene:", + { + hasProject: !!activeProject, + hasScene: !!currentScene, + sceneName: currentScene?.name, + } + ); } }; @@ -547,16 +591,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { get().selectElement(trackId, newElement.id); }, - removeElementFromTrack: (trackId, elementId, pushHistory = true) => { - const { rippleEditingEnabled } = get(); + removeElementFromTrackWithRipple: ( + trackId, + elementId, + pushHistory = true + ) => { + const { _tracks, rippleEditingEnabled } = get(); - if (rippleEditingEnabled) { - get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory); - } else { + if (!rippleEditingEnabled) { + // Inline non-ripple removal logic if (pushHistory) get().pushHistory(); updateTracksAndSave( - get() - ._tracks.map((track) => + _tracks + .map((track) => track.id === trackId ? { ...track, @@ -568,18 +615,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ) .filter((track) => track.elements.length > 0) ); - } - }, - - removeElementFromTrackWithRipple: ( - trackId, - elementId, - pushHistory = true - ) => { - const { _tracks, rippleEditingEnabled } = get(); - - if (!rippleEditingEnabled) { - get().removeElementFromTrack(trackId, elementId, pushHistory); return; } @@ -836,42 +871,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ); }, - toggleElementHidden: (trackId, elementId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId - ? { ...element, hidden: !element.hidden } - : element - ), - } - : track - ) - ); - }, - - toggleElementMuted: (trackId, elementId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId && element.type === "media" - ? { ...element, muted: !element.muted } - : element - ), - } - : track - ) - ); - }, - updateTextElement: (trackId, elementId, updates) => { get().pushHistory(); updateTracksAndSave( @@ -890,60 +889,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ); }, - splitElement: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - - if (!element) return null; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; - - get().pushHistory(); - - const relativeTime = splitTime - element.startTime; - const firstDuration = relativeTime; - const secondDuration = - element.duration - element.trimStart - element.trimEnd - relativeTime; - - const secondElementId = generateUUID(); - - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.flatMap((c) => - c.id === elementId - ? [ - { - ...c, - trimEnd: c.trimEnd + secondDuration, - name: getElementNameWithSuffix(c.name, "left"), - }, - { - ...c, - id: secondElementId, - startTime: splitTime, - trimStart: c.trimStart + firstDuration, - name: getElementNameWithSuffix(c.name, "right"), - }, - ] - : [c] - ), - } - : track - ) - ); - - return secondElementId; - }, - // Split element and keep only the left portion splitAndKeepLeft: (trackId, elementId, splitTime) => { const { _tracks } = get(); @@ -1122,9 +1067,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }; } - const mediaData: any = { + const mediaData: Omit<MediaFile, "id"> = { name: newFile.name, - type: fileType, + type: fileType as MediaType, file: newFile, url: URL.createObjectURL(newFile), }; @@ -1229,8 +1174,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { getProjectThumbnail: async (projectId) => { try { - const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaFiles(projectId); + const project = await storageService.loadProject({ id: projectId }); + if (!project) return null; + + // For scene-based projects, use main scene timeline + // For legacy projects, use legacy timeline format + let sceneId: string | undefined; + if (project.scenes && project.scenes.length > 0) { + const mainScene = project.scenes.find((s) => s.isMain); + sceneId = mainScene?.id; + } + + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + const mediaItems = await storageService.loadAllMediaFiles({ + projectId, + }); if (!tracks || !mediaItems.length) return null; @@ -1330,9 +1291,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }); }, - loadProjectTimeline: async (projectId) => { + loadProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { try { - const tracks = await storageService.loadTimeline(projectId); + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + if (tracks) { updateTracks(tracks); } else { @@ -1348,9 +1319,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } }, - saveProjectTimeline: async (projectId) => { + saveProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { + const { _tracks } = get(); try { - await storageService.saveTimeline(projectId, get()._tracks); + await storageService.saveTimeline({ + projectId, + tracks: _tracks, + sceneId, + }); } catch (error) { console.error("Failed to save timeline:", error); } @@ -1541,6 +1523,332 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } } }, + + deleteSelected: (trackId?: string, elementId?: string) => { + const { selectedElements, rippleEditingEnabled } = get(); + + const elementsToDelete = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToDelete.length === 0) return; + + get().pushHistory(); + + if (rippleEditingEnabled) { + for (const { trackId: tId, elementId: eId } of elementsToDelete) { + get().removeElementFromTrackWithRipple(tId, eId, false); + } + } else { + updateTracksAndSave( + get() + ._tracks.map((track) => ({ + ...track, + elements: track.elements.filter( + (element) => + !elementsToDelete.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ) + ), + })) + .filter((track) => track.elements.length > 0) + ); + } + + get().clearSelectedElements(); + }, + + splitSelected: (splitTime, trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + const elementsToSplit: Array<{ + trackId: string; + elementId: string; + element: TimelineElement; + }> = []; + + for (const { trackId: tId, elementId: eId } of elementsToProcess) { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + if (!track || !element) continue; + + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + elementsToSplit.push({ trackId: tId, elementId: eId, element }); + } + } + + if (elementsToSplit.length === 0) { + const { toast } = require("sonner"); + const isMultiple = elementsToProcess.length > 1; + toast.error( + isMultiple + ? "Playhead must be within all selected elements to split" + : "Playhead must be within element to split" + ); + return; + } + + get().pushHistory(); + + updateTracksAndSave( + get()._tracks.map((track) => { + const elementsToSplitInTrack = elementsToSplit.filter( + ({ trackId: tId }) => tId === track.id + ); + + if (elementsToSplitInTrack.length === 0) return track; + + return { + ...track, + elements: track.elements.flatMap((c) => { + const elementToSplit = elementsToSplitInTrack.find( + ({ elementId: eId }) => eId === c.id + ); + + if (!elementToSplit) return [c]; + + const relativeTime = splitTime - elementToSplit.element.startTime; + const firstDuration = relativeTime; + const secondDuration = + elementToSplit.element.duration - + elementToSplit.element.trimStart - + elementToSplit.element.trimEnd - + relativeTime; + + const secondElementId = generateUUID(); + + return [ + { + ...c, + trimEnd: c.trimEnd + secondDuration, + name: getElementNameWithSuffix(c.name, "left"), + }, + { + ...c, + id: secondElementId, + startTime: splitTime, + trimStart: c.trimStart + firstDuration, + name: getElementNameWithSuffix(c.name, "right"), + }, + ]; + }), + }; + }) + ); + }, + + toggleSelectedHidden: (trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + get().pushHistory(); + + const shouldHide = elementsToProcess.some( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element && !element.hidden; + } + ); + + updateTracksAndSave( + _tracks.map((track) => ({ + ...track, + elements: track.elements.map((element) => { + const shouldUpdate = elementsToProcess.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ); + return shouldUpdate && element.hidden !== shouldHide + ? { ...element, hidden: shouldHide } + : element; + }), + })) + ); + }, + + toggleSelectedMuted: (trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + get().pushHistory(); + + const audioElements = elementsToProcess.filter( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element?.type === "media"; + } + ); + + if (audioElements.length === 0) return; + + const shouldMute = audioElements.some( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element?.type === "media" && !element.muted; + } + ); + + updateTracksAndSave( + _tracks.map((track) => ({ + ...track, + elements: track.elements.map((element) => { + const shouldUpdate = audioElements.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ); + return shouldUpdate && + element.type === "media" && + element.muted !== shouldMute + ? { ...element, muted: shouldMute } + : element; + }), + })) + ); + }, + + duplicateElement: (trackId, elementId) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); + if (!track || !element) return; + + const { id, ...elementWithoutId } = element; + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + + get().addElementToTrack(trackId, { + ...elementWithoutId, + name: `${element.name} (copy)`, + startTime: element.startTime + effectiveDuration + 0.1, + } as CreateTimelineElement); + }, + + revealElementInMedia: (elementId) => { + const { + useMediaPanelStore, + } = require("../components/editor/media-panel/store"); + const { requestRevealMedia } = useMediaPanelStore.getState(); + + const { _tracks } = get(); + const element = _tracks + .flatMap((track) => track.elements) + .find((el) => el.id === elementId); + + if (element?.type === "media") { + requestRevealMedia(element.mediaId); + } + }, + + replaceElementWithFile: async (trackId, elementId, file) => { + try { + const result = await get().replaceElementMedia( + trackId, + elementId, + file + ); + if (result.success) { + const { toast } = await import("sonner"); + toast.success("Clip replaced successfully"); + } else { + const { toast } = await import("sonner"); + toast.error(result.error || "Failed to replace clip"); + } + } catch (error) { + console.error("Unexpected error replacing clip:", error); + const { toast } = await import("sonner"); + toast.error( + `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + }, + + getContextMenuState: (trackId, elementId) => { + const { selectedElements, _tracks } = get(); + const { currentTime } = usePlaybackStore.getState(); + const { mediaFiles } = useMediaStore.getState(); + + const isMultipleSelected = selectedElements.length > 1; + const isCurrentElementSelected = selectedElements.some( + (sel) => sel.trackId === trackId && sel.elementId === elementId + ); + + const hasAudioElements = selectedElements.some( + ({ trackId: tId, elementId: eId }) => { + const selectedTrack = _tracks.find((t) => t.id === tId); + const selectedElement = selectedTrack?.elements.find( + (e) => e.id === eId + ); + if (selectedElement?.type !== "media") return false; + const mediaElement = selectedElement as MediaElement; + const mediaItem = mediaFiles.find( + (file: MediaFile) => file.id === mediaElement.mediaId + ); + return mediaItem?.type === "audio" || mediaItem?.type === "video"; + } + ); + + const canSplitSelected = selectedElements.every( + ({ trackId: tId, elementId: eId }) => { + const selectedTrack = _tracks.find((t) => t.id === tId); + const selectedElement = selectedTrack?.elements.find( + (e) => e.id === eId + ); + if (!selectedElement) return false; + const effectiveStart = selectedElement.startTime; + const effectiveEnd = + selectedElement.startTime + + (selectedElement.duration - + selectedElement.trimStart - + selectedElement.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + } + ); + + return { + isMultipleSelected, + isCurrentElementSelected, + hasAudioElements, + canSplitSelected, + currentTime, + }; + }, }; }); diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index bf510942..f182653d 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -1,15 +1,27 @@ import { CanvasSize } from "./editor"; +export type BlurIntensity = 4 | 8 | 18; + +export interface Scene { + id: string; + name: string; + isMain: boolean; + createdAt: Date; + updatedAt: Date; +} + export interface TProject { id: string; name: string; thumbnail: string; createdAt: Date; updatedAt: Date; + scenes: Scene[]; + currentSceneId: string; mediaItems?: string[]; backgroundColor?: string; backgroundType?: "color" | "blur"; - blurIntensity?: number; // in pixels (4, 8, 18) + blurIntensity?: BlurIntensity; fps?: number; bookmarks?: number[]; canvasSize: CanvasSize; |
