Merge branch 'staging'

This commit is contained in:
Maze Winther 2025-09-03 00:02:07 +02:00
commit 60debe2ce5
49 changed files with 3814 additions and 1283 deletions

View File

@ -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 };
}

View File

@ -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>;
}

View File

@ -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

View File

@ -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",
},
},
{

View File

@ -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>
</>
)}
<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>
{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
variant="outline"
className="rounded-md w-full"
onClick={() => {}}
>
Cancel
</Button>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button
variant="outline"
className="rounded-md w-full"
onClick={() => {}}
>
Cancel
</Button>
)}
</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>
</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>
);
}

View File

@ -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"
)}
/>
);
}

View File

@ -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}

View File

@ -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}

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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);
// Offscreen buffer to avoid flicker (reuse canvas)
// 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");
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))
)}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 (

View File

@ -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"

View File

@ -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">

View File

@ -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,
]);
}

View File

@ -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;
}

View File

@ -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)

View File

@ -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) => {

View File

@ -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>
);

View File

@ -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,
};

View File

@ -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}

View File

@ -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}

View File

@ -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 };

View File

@ -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)",
];

View File

@ -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
);

View File

@ -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,
};
}

View File

@ -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) {

View File

@ -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);
}
}
}
}

View File

@ -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,
});

View File

@ -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";
}
}

View File

@ -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);

View File

@ -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[];
};

View File

@ -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();
}
}

View File

@ -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();

View File

@ -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";

View File

@ -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) {

View File

@ -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);

View File

@ -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,
});
},
}));

View File

@ -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) => ({

View File

@ -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 = [

View File

@ -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,
};
},
};
});

View File

@ -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;