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