summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/app/editor/[project_id]/layout.tsx22
-rw-r--r--apps/web/src/app/layout.tsx5
-rw-r--r--apps/web/src/app/roadmap/page.tsx4
-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
-rw-r--r--apps/web/src/components/footer.tsx17
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx4
-rw-r--r--apps/web/src/components/providers/global-prefetcher.ts78
-rw-r--r--apps/web/src/components/providers/migrators/scenes-migrator.tsx177
-rw-r--r--apps/web/src/components/ui/button.tsx4
-rw-r--r--apps/web/src/components/ui/dialog.tsx4
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx4
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx4
-rw-r--r--apps/web/src/components/ui/popover.tsx2
-rw-r--r--apps/web/src/components/ui/sheet.tsx12
-rw-r--r--apps/web/src/components/ui/split-button.tsx90
-rw-r--r--apps/web/src/data/colors/syntax-ui.tsx64
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts13
-rw-r--r--apps/web/src/hooks/use-frame-cache.ts348
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts6
-rw-r--r--apps/web/src/lib/canvas-gradients.ts219
-rw-r--r--apps/web/src/lib/export.ts23
-rw-r--r--apps/web/src/lib/fetch-github-stars.ts29
-rw-r--r--apps/web/src/lib/storage/storage-service.ts162
-rw-r--r--apps/web/src/lib/storage/types.ts20
-rw-r--r--apps/web/src/lib/timeline-renderer.ts208
-rw-r--r--apps/web/src/lib/video-cache.ts186
-rw-r--r--apps/web/src/stores/keybindings-store.ts4
-rw-r--r--apps/web/src/stores/media-store.ts19
-rw-r--r--apps/web/src/stores/project-store.ts158
-rw-r--r--apps/web/src/stores/scene-store.ts318
-rw-r--r--apps/web/src/stores/sounds-store.ts4
-rw-r--r--apps/web/src/stores/stickers-store.ts53
-rw-r--r--apps/web/src/stores/timeline-store.ts580
-rw-r--r--apps/web/src/types/project.ts14
48 files changed, 3800 insertions, 1282 deletions
diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx
index 151f7f94..c37e78b7 100644
--- a/apps/web/src/app/editor/[project_id]/layout.tsx
+++ b/apps/web/src/app/editor/[project_id]/layout.tsx
@@ -1,13 +1,9 @@
-"use client";
-
-import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher";
-
-export default function EditorLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- useGlobalPrefetcher();
-
- return <div>{children}</div>;
-}
+"use client";
+
+export default function EditorLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return <div>{children}</div>;
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index ee0bdbd2..7e32ea22 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -5,6 +5,7 @@ import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { StorageProvider } from "../components/storage-provider";
+import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator";
import { baseMetaData } from "./metadata";
import { defaultFont } from "../lib/font-config";
import { BotIdClient } from "botid/client";
@@ -36,7 +37,9 @@ export default function RootLayout({
<body className={`${defaultFont.className} font-sans antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark">
<TooltipProvider>
- <StorageProvider>{children}</StorageProvider>
+ <StorageProvider>
+ <ScenesMigrator>{children}</ScenesMigrator>
+ </StorageProvider>
<Analytics />
<Toaster />
<Script
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
index b26da5aa..b5397b63 100644
--- a/apps/web/src/app/roadmap/page.tsx
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -47,8 +47,8 @@ const roadmapItems: {
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
- text: "In Progress",
- type: "pending",
+ text: "Completed",
+ type: "complete",
},
},
{
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index c9738e81..246e8b6f 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -16,7 +16,7 @@ import {
DEFAULT_EXPORT_OPTIONS,
} from "@/lib/export";
import { useProjectStore } from "@/stores/project-store";
-import { Download, X } from "lucide-react";
+import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
@@ -132,137 +132,186 @@ function ExportPopover({
};
return (
- <PopoverContent className="w-80 mr-4 flex flex-col gap-3">
+ <PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
<>
- <div className="flex items-center justify-between">
- <h3 className=" font-medium">
- {isExporting ? "Exporting project" : "Export project"}
- </h3>
- <Button variant="text" size="icon" onClick={handleClose}>
- <X className="!size-5 text-foreground/85" />
- </Button>
- </div>
+ {exportResult && !exportResult.success ? (
+ <ExportError
+ error={exportResult.error || "Unknown error occurred"}
+ onRetry={handleExport}
+ />
+ ) : (
+ <>
+ <div className="flex items-center justify-between">
+ <h3 className=" font-medium">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ <Button variant="text" size="icon" onClick={handleClose}>
+ <X className="!size-5 text-foreground/85" />
+ </Button>
+ </div>
- <div className="flex flex-col gap-4">
- {!isExporting && (
- <>
- <div className="flex flex-col gap-3">
- <PropertyGroup
- title="Format"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <RadioGroup
- value={format}
- onValueChange={(value) => setFormat(value as ExportFormat)}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="mp4" id="mp4" />
- <Label htmlFor="mp4">
- MP4 (H.264) - Better compatibility
- </Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="webm" id="webm" />
- <Label htmlFor="webm">
- WebM (VP9) - Smaller file size
- </Label>
- </div>
- </RadioGroup>
- </PropertyGroup>
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col gap-3">
+ <PropertyGroup
+ title="Format"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={format}
+ onValueChange={(value) =>
+ setFormat(value as ExportFormat)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
- <PropertyGroup
- title="Quality"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <RadioGroup
- value={quality}
- onValueChange={(value) =>
- setQuality(value as ExportQuality)
- }
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="low" id="low" />
- <Label htmlFor="low">Low - Smallest file size</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="medium" id="medium" />
- <Label htmlFor="medium">Medium - Balanced</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="high" id="high" />
- <Label htmlFor="high">High - Recommended</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="very_high" id="very_high" />
- <Label htmlFor="very_high">
- Very High - Largest file size
- </Label>
- </div>
- </RadioGroup>
- </PropertyGroup>
+ <PropertyGroup
+ title="Quality"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) =>
+ setQuality(value as ExportQuality)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very High - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
- <PropertyGroup
- title="Audio"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <div className="flex items-center space-x-2">
- <Checkbox
- id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) => setIncludeAudio(!!checked)}
- />
- <Label htmlFor="include-audio">
- Include audio in export
- </Label>
+ <PropertyGroup
+ title="Audio"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="include-audio"
+ checked={includeAudio}
+ onCheckedChange={(checked) =>
+ setIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
+ </Label>
+ </div>
+ </PropertyGroup>
</div>
- </PropertyGroup>
- </div>
- <Button onClick={handleExport} className="w-full gap-2">
- <Download className="w-4 h-4" />
- Export
- </Button>
- </>
- )}
-
- {isExporting && (
- <div className="space-y-4">
- <div className="flex flex-col">
- <div className="text-center flex items-center justify-between">
- <p className="text-sm text-muted-foreground mb-2">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-sm text-muted-foreground mb-2">100%</p>
- </div>
- <Progress value={progress * 100} className="w-full" />
- </div>
+ <Button onClick={handleExport} className="w-full gap-2">
+ <Download className="w-4 h-4" />
+ Export
+ </Button>
+ </>
+ )}
- <Button
- variant="outline"
- className="rounded-md w-full"
- onClick={() => {}}
- >
- Cancel
- </Button>
- </div>
- )}
+ {isExporting && (
+ <div className="space-y-4">
+ <div className="flex flex-col">
+ <div className="text-center flex items-center justify-between">
+ <p className="text-sm text-muted-foreground mb-2">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-sm text-muted-foreground mb-2">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
- {exportResult && !exportResult.success && (
- <div className="text-center space-y-3">
- <div className="text-red-600 font-medium">Export failed</div>
- <p className="text-sm text-muted-foreground">
- {exportResult.error || "Unknown error occurred"}
- </p>
- <Button variant="outline" onClick={() => setExportResult(null)}>
- Try Again
- </Button>
+ <Button
+ variant="outline"
+ className="rounded-md w-full"
+ onClick={() => {}}
+ >
+ Cancel
+ </Button>
+ </div>
+ )}
</div>
- )}
- </div>
+ </>
+ )}
</>
</PopoverContent>
);
}
+
+function ExportError({
+ error,
+ onRetry,
+}: {
+ error: string;
+ onRetry: () => void;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(error);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1000);
+ };
+
+ return (
+ <div className="space-y-4">
+ <div className="flex flex-col gap-1.5">
+ <p className="text-sm font-medium text-red-400">Export failed</p>
+ <p className="text-xs text-muted-foreground">
+ {error}
+ </p>
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex-1 text-xs h-8"
+ onClick={handleCopy}
+ >
+ {copied ? <Check className="text-green-500" /> : <Copy />}
+ Copy
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex-1 text-xs h-8"
+ onClick={onRetry}
+ >
+ <RotateCcw />
+ Retry
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx
index 56437508..e2bfdff7 100644
--- a/apps/web/src/components/editor/media-panel/tabbar.tsx
+++ b/apps/web/src/components/editor/media-panel/tabbar.tsx
@@ -7,13 +7,45 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { useEffect, useRef, useState } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+
+ const checkScrollPosition = () => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ setShowTopFade(scrollTop > 0);
+ setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
+ };
+
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ checkScrollPosition();
+ element.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, []);
return (
- <div className="flex">
- <div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4">
+ <div className="flex relative">
+ <div
+ ref={scrollRef}
+ className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
+ >
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
@@ -46,6 +78,22 @@ export function TabBar() {
);
})}
</div>
+
+ <FadeOverlay direction="top" show={showTopFade} />
+ <FadeOverlay direction="bottom" show={showBottomFade} />
</div>
);
}
+
+function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) {
+ return (
+ <div
+ className={cn(
+ "absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
+ direction === "top" && show
+ ? "top-0 bg-gradient-to-b from-panel to-transparent"
+ : "bottom-0 bg-gradient-to-t from-panel to-transparent"
+ )}
+ />
+ );
+} \ No newline at end of file
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
index 59403a8d..fbe5b418 100644
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ b/apps/web/src/components/editor/media-panel/views/captions.tsx
@@ -188,7 +188,7 @@ export function Captions() {
};
return (
- <BaseView ref={containerRef} className="flex flex-col justify-between">
+ <BaseView ref={containerRef} className="flex flex-col justify-between h-full">
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx
index df1ec54f..be54a9c4 100644
--- a/apps/web/src/components/editor/media-panel/views/settings.tsx
+++ b/apps/web/src/components/editor/media-panel/views/settings.tsx
@@ -16,15 +16,18 @@ import {
} from "../../properties-panel/property-item";
import { FPS_PRESETS } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
+import type { BlurIntensity } from "@/types/project";
import { useEditorStore } from "@/stores/editor-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import Image from "next/image";
import { cn } from "@/lib/utils";
import { colors } from "@/data/colors/solid";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { PipetteIcon } from "lucide-react";
+import { PipetteIcon, PlusIcon } from "lucide-react";
import { useMemo, memo, useCallback } from "react";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
export function SettingsView() {
return <ProjectSettingsTabs />;
@@ -38,14 +41,40 @@ function ProjectSettingsTabs() {
{
value: "project-info",
label: "Project info",
- content: <ProjectInfoView />,
+ content: (
+ <div className="p-5">
+ <ProjectInfoView />
+ </div>
+ ),
},
{
value: "background",
label: "Background",
- content: <BackgroundView />,
+ content: (
+ <div className="flex flex-col justify-between h-full">
+ <div className="flex-1 p-5">
+ <BackgroundView />
+ </div>
+ <div className="flex flex-col sticky -bottom-0 bg-panel/85 backdrop-blur-lg">
+ <Separator />
+ <Button className="w-fit h-auto p-5 py-4 !bg-transparent shadow-none text-muted-foreground hover:text-foreground/85 text-xs">
+ Custom background
+ <PlusIcon />
+ </Button>
+ </div>
+
+ {/* Another UI, looks so beautiful i don't wanna remove it */}
+ {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
+ <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
+ <span className="text-sm">Custom</span>
+ <PlusIcon className="" />
+ </Button>
+ </div> */}
+ </div>
+ ),
},
]}
+ className="flex flex-col justify-between h-full p-0"
/>
);
}
@@ -177,9 +206,9 @@ const BackgroundPreviews = memo(
}) => {
return useMemo(
() =>
- backgrounds.map((bg) => (
+ backgrounds.map((bg, index) => (
<div
- key={bg}
+ key={`${index}-${bg}`}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary",
isColorBackground &&
@@ -215,7 +244,7 @@ BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const { activeProject, updateBackgroundType } = useProjectStore();
- const blurLevels = useMemo(
+ const blurLevels = useMemo<Array<{ label: string; value: BlurIntensity }>>(
() => [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
@@ -225,7 +254,7 @@ function BackgroundView() {
);
const handleBlurSelect = useCallback(
- async (blurIntensity: number) => {
+ async (blurIntensity: BlurIntensity) => {
await updateBackgroundType("blur", { blurIntensity });
},
[updateBackgroundType]
@@ -257,7 +286,7 @@ function BackgroundView() {
);
return (
- <div className="flex flex-col gap-4">
+ <div className="flex flex-col gap-4 h-full">
<PropertyGroup title="Blur" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
</PropertyGroup>
@@ -277,7 +306,7 @@ function BackgroundView() {
</div>
</PropertyGroup>
- <PropertyGroup title="Pattern Craft" defaultExpanded={false}>
+ <PropertyGroup title="Pattern craft" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">
<BackgroundPreviews
backgrounds={patternCraftGradients}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
index 303eb858..700eaea7 100644
--- a/apps/web/src/components/editor/media-panel/views/sounds.tsx
+++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx
@@ -82,6 +82,14 @@ function SoundEffectsView() {
toggleSavedSound,
showCommercialOnly,
toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
} = useSoundsStore();
const {
results: searchResults,
@@ -106,6 +114,55 @@ function SoundEffectsView() {
useEffect(() => {
loadSavedSounds();
+ if (!hasLoaded) {
+ let ignore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!ignore) {
+ setLoading(true);
+ setError(null);
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads"
+ );
+
+ if (!ignore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects(data.results);
+ setHasLoaded(true);
+
+ setCurrentPage(1);
+ setHasNextPage(!!data.next);
+ setTotalCount(data.count);
+ }
+ } catch (error) {
+ if (!ignore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError(
+ error instanceof Error ? error.message : "Failed to load sounds"
+ );
+ }
+ } finally {
+ if (!ignore) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }
+
if (scrollAreaRef.current && scrollPosition > 0) {
const timeoutId = setTimeout(() => {
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
@@ -113,14 +170,23 @@ function SoundEffectsView() {
return () => clearTimeout(timeoutId);
}
- }, []);
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
setScrollPosition(scrollTop);
handleScroll(event);
};
-
+
const displayedSounds = useMemo(() => {
const sounds = searchQuery ? searchResults : topSoundEffects;
return sounds;
diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx
index 41afe285..f67450cc 100644
--- a/apps/web/src/components/editor/media-panel/views/stickers.tsx
+++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx
@@ -20,8 +20,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
-import { Separator } from "@/components/ui/separator";
+import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
Tooltip,
TooltipContent,
@@ -48,63 +47,41 @@ export function StickersView() {
const { selectedCategory, setSelectedCategory } = useStickersStore();
return (
- <div className="h-full flex flex-col">
- <Tabs
- value={selectedCategory}
- onValueChange={(v) => {
- if (["all", "general", "brands", "emoji"].includes(v)) {
- setSelectedCategory(v as StickerCategory);
- }
- }}
- className="flex flex-col h-full"
- >
- <div className="px-3 pt-4 pb-0">
- <TabsList>
- <TabsTrigger value="all" className="gap-1">
- <Grid3X3 className="h-3 w-3" />
- All
- </TabsTrigger>
- <TabsTrigger value="general" className="gap-1">
- <Sparkles className="h-3 w-3" />
- Icons
- </TabsTrigger>
- <TabsTrigger value="brands" className="gap-1">
- <Hash className="h-3 w-3" />
- Brands
- </TabsTrigger>
- <TabsTrigger value="emoji" className="gap-1">
- <Smile className="h-3 w-3" />
- Emoji
- </TabsTrigger>
- </TabsList>
- </div>
- <Separator className="my-4" />
- <TabsContent
- value="all"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="all" />
- </TabsContent>
- <TabsContent
- value="general"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="general" />
- </TabsContent>
- <TabsContent
- value="brands"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="brands" />
- </TabsContent>
- <TabsContent
- value="emoji"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="emoji" />
- </TabsContent>
- </Tabs>
- </div>
+ <BaseView
+ value={selectedCategory}
+ onValueChange={(v) => {
+ if (["all", "general", "brands", "emoji"].includes(v)) {
+ setSelectedCategory(v as StickerCategory);
+ }
+ }}
+ tabs={[
+ {
+ value: "all",
+ label: "All",
+ icon: <Grid3X3 className="h-3 w-3" />,
+ content: <StickersContentView category="all" />,
+ },
+ {
+ value: "general",
+ label: "Icons",
+ icon: <Sparkles className="h-3 w-3" />,
+ content: <StickersContentView category="general" />,
+ },
+ {
+ value: "brands",
+ label: "Brands",
+ icon: <Hash className="h-3 w-3" />,
+ content: <StickersContentView category="brands" />,
+ },
+ {
+ value: "emoji",
+ label: "Emoji",
+ icon: <Smile className="h-3 w-3" />,
+ content: <StickersContentView category="emoji" />,
+ },
+ ]}
+ className="flex flex-col h-full p-0 overflow-hidden"
+ />
);
}
@@ -204,12 +181,12 @@ function StickersContentView({ category }: { category: StickerCategory }) {
setSelectedCollection,
loadCollections,
searchStickers,
- downloadSticker,
+ addStickerToTimeline,
clearRecentStickers,
setSelectedCategory,
+ addingSticker,
} = useStickersStore();
- const [addingSticker, setAddingSticker] = useState<string | null>(null);
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [collectionsToShow, setCollectionsToShow] = useState(20);
const [showCollectionItems, setShowCollectionItems] = useState(false);
@@ -273,48 +250,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, [localSearchQuery]);
const handleAddSticker = async (iconName: string) => {
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setAddingSticker(iconName);
-
try {
- const file = await downloadSticker(iconName);
-
- if (!file) {
- throw new Error("Failed to download sticker");
- }
-
- const mediaItem: Omit<MediaFile, "id"> = {
- name: iconName.replace(":", "-"),
- type: "image",
- file,
- url: URL.createObjectURL(file),
- width: 200,
- height: 200,
- duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
- ephemeral: false,
- };
-
- await addMediaFile(activeProject.id, mediaItem);
-
- const added = useMediaStore
- .getState()
- .mediaFiles.find(
- (m) => m.url === mediaItem.url && m.name === mediaItem.name
- );
- if (!added) throw new Error("Sticker not in media store");
-
- addElementAtTime(added, currentTime);
-
- toast.success(`Added "${iconName}" to timeline`);
+ await addStickerToTimeline(iconName);
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
- } finally {
- setAddingSticker(null);
}
};
@@ -363,7 +303,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, [isInCollection]);
return (
- <div className="flex flex-col gap-5 mt-1 h-full">
+ <div className="flex flex-col gap-5 mt-1 h-full p-4">
<div className="space-y-3">
<InputWithBack
isExpanded={isInCollection}
@@ -383,6 +323,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}
value={localSearchQuery}
onChange={setLocalSearchQuery}
+ disableAnimation={true}
/>
</div>
diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx
index ec01028c..6e92c616 100644
--- a/apps/web/src/components/editor/panel-base-view.tsx
+++ b/apps/web/src/components/editor/panel-base-view.tsx
@@ -1,6 +1,7 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
interface PanelBaseViewProps {
children?: React.ReactNode;
@@ -10,6 +11,7 @@ interface PanelBaseViewProps {
tabs?: {
value: string;
label: string;
+ icon?: React.ReactNode;
content: React.ReactNode;
}[];
className?: string;
@@ -25,7 +27,7 @@ function ViewContent({
}) {
return (
<ScrollArea className="flex-1">
- <div className={`p-5 h-full ${className}`}>{children}</div>
+ <div className={cn("p-5", className)}>{children}</div>
</ScrollArea>
);
}
@@ -40,7 +42,7 @@ export function PanelBaseView({
ref,
}: PanelBaseViewProps) {
return (
- <div className={`h-full flex flex-col ${className}`} ref={ref}>
+ <div className={cn("h-full flex flex-col", className)} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
@@ -55,6 +57,11 @@ export function PanelBaseView({
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
+ {tab.icon ? (
+ <span className="inline-flex items-center mr-1">
+ {tab.icon}
+ </span>
+ ) : null}
{tab.label}
</TabsTrigger>
))}
@@ -68,7 +75,7 @@ export function PanelBaseView({
value={tab.value}
className="mt-0 flex-1 flex flex-col min-h-0"
>
- <ViewContent>{tab.content}</ViewContent>
+ <ViewContent className={className}>{tab.content}</ViewContent>
</TabsContent>
))}
</Tabs>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 1654624c..36f6a2ba 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -13,6 +13,8 @@ import { renderTimelineFrame } from "@/lib/timeline-renderer";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { useFrameCache } from "@/hooks/use-frame-cache";
+import { useSceneStore } from "@/stores/scene-store";
import {
DEFAULT_CANVAS_SIZE,
DEFAULT_FPS,
@@ -42,8 +44,11 @@ export function PreviewPanel() {
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { isPlaying, volume, muted } = usePlaybackStore();
const { activeProject } = useProjectStore();
+ const { currentScene } = useSceneStore();
const previewRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
+ const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
+ useFrameCache();
const lastFrameTimeRef = useRef(0);
const renderSeqRef = useRef(0);
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
@@ -216,6 +221,16 @@ export function PreviewPanel() {
};
}, [dragState, previewDimensions, canvasSize, updateTextElement]);
+ // Clear the frame cache when background settings change since they affect rendering
+ useEffect(() => {
+ invalidateCache();
+ }, [
+ mediaFiles,
+ activeProject?.backgroundColor,
+ activeProject?.backgroundType,
+ invalidateCache,
+ ]);
+
const handleTextMouseDown = (
e: React.MouseEvent<HTMLDivElement>,
element: any,
@@ -246,6 +261,7 @@ export function PreviewPanel() {
}, []);
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
+ const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType;
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
@@ -449,7 +465,7 @@ export function PreviewPanel() {
};
}, [isPlaying, volume, muted, mediaFiles]);
- // Canvas: draw current frame for visible elements using offscreen compositing
+ // Canvas: draw current frame with caching
useEffect(() => {
const draw = async () => {
const canvas = canvasRef.current;
@@ -475,10 +491,94 @@ export function PreviewPanel() {
lastFrameTimeRef.current = currentTime;
}
- // Invalidate older async renders when user scrubs rapidly
- const mySeq = (renderSeqRef.current += 1);
+ const cachedFrame = getCachedFrame(
+ currentTime,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ );
+ if (cachedFrame) {
+ mainCtx.putImageData(cachedFrame, 0, 0);
+
+ // Pre-render nearby frames in background
+ if (!isPlaying) {
+ // Only during scrubbing to avoid interfering with playback
+ preRenderNearbyFrames(
+ currentTime,
+ tracks,
+ mediaFiles,
+ activeProject,
+ async (time: number) => {
+ const tempCanvas = document.createElement("canvas");
+ tempCanvas.width = displayWidth;
+ tempCanvas.height = displayHeight;
+ const tempCtx = tempCanvas.getContext("2d");
+ if (!tempCtx)
+ throw new Error("Failed to create temp canvas context");
- // Offscreen buffer to avoid flicker (reuse canvas)
+ await renderTimelineFrame({
+ ctx: tempCtx,
+ time,
+ canvasWidth: displayWidth,
+ canvasHeight: displayHeight,
+ tracks,
+ mediaFiles,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? undefined
+ : activeProject?.backgroundColor || "#000000",
+ projectCanvasSize: canvasSize,
+ });
+
+ return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
+ },
+ currentScene?.id,
+ 3
+ );
+ } else {
+ // Small lookahead while playing
+ preRenderNearbyFrames(
+ currentTime,
+ tracks,
+ mediaFiles,
+ activeProject,
+ async (time: number) => {
+ const tempCanvas = document.createElement("canvas");
+ tempCanvas.width = displayWidth;
+ tempCanvas.height = displayHeight;
+ const tempCtx = tempCanvas.getContext("2d");
+ if (!tempCtx)
+ throw new Error("Failed to create temp canvas context");
+
+ await renderTimelineFrame({
+ ctx: tempCtx,
+ time,
+ canvasWidth: displayWidth,
+ canvasHeight: displayHeight,
+ tracks,
+ mediaFiles,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? undefined
+ : activeProject?.backgroundColor || "#000000",
+ projectCanvasSize: canvasSize,
+ });
+
+ return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
+ },
+ currentScene?.id,
+ 1
+ );
+ }
+ return;
+ }
+
+ // Cache miss - render from scratch
if (!offscreenCanvasRef.current) {
const hasOffscreen =
typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
@@ -534,13 +634,30 @@ export function PreviewPanel() {
canvasHeight: displayHeight,
tracks,
mediaFiles,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
backgroundColor:
activeProject?.backgroundType === "blur"
- ? "transparent"
+ ? undefined
: activeProject?.backgroundColor || "#000000",
projectCanvasSize: canvasSize,
});
+ const imageData = (offCtx as CanvasRenderingContext2D).getImageData(
+ 0,
+ 0,
+ displayWidth,
+ displayHeight
+ );
+ cacheFrame(
+ currentTime,
+ imageData,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ );
+
// Blit offscreen to visible canvas
mainCtx.clearRect(0, 0, displayWidth, displayHeight);
if ((offscreenCanvas as HTMLCanvasElement).getContext) {
@@ -564,6 +681,10 @@ export function PreviewPanel() {
canvasSize.height,
activeProject?.backgroundType,
activeProject?.backgroundColor,
+ getCachedFrame,
+ cacheFrame,
+ preRenderNearbyFrames,
+ isPlaying,
]);
// Get media elements for blur background (video/image only)
@@ -593,7 +714,7 @@ export function PreviewPanel() {
className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0"
>
<div className="flex-1" />
- {hasAnyElements ? (
+ {shouldRenderPreview ? (
<div
ref={previewRef}
className="relative overflow-hidden border"
@@ -619,9 +740,7 @@ export function PreviewPanel() {
aria-label="Video preview canvas"
/>
{activeElements.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
- No elements at current time
- </div>
+ <></>
) : (
activeElements.map((elementData) => renderElement(elementData))
)}
diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx
new file mode 100644
index 00000000..b5432b53
--- /dev/null
+++ b/apps/web/src/components/editor/scenes-view.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { useSceneStore } from "@/stores/scene-store";
+import { Check, ListCheck, Trash2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+export function ScenesView({ children }: { children: React.ReactNode }) {
+ const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
+ const [isSelectMode, setIsSelectMode] = useState(false);
+ const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
+
+ const handleSceneSwitch = async (sceneId: string) => {
+ if (isSelectMode) {
+ toggleSceneSelection(sceneId);
+ return;
+ }
+
+ try {
+ await switchToScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to switch scene:", error);
+ }
+ };
+
+ const toggleSceneSelection = (sceneId: string) => {
+ setSelectedScenes((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(sceneId)) {
+ newSet.delete(sceneId);
+ } else {
+ newSet.add(sceneId);
+ }
+ return newSet;
+ });
+ };
+
+ const handleSelectMode = () => {
+ setIsSelectMode(!isSelectMode);
+ setSelectedScenes(new Set());
+ };
+
+ const handleDeleteSelected = async () => {
+ for (const sceneId of selectedScenes) {
+ const scene = scenes.find((s) => s.id === sceneId);
+ if (scene && !scene.isMain) {
+ try {
+ await deleteScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to delete scene:", error);
+ }
+ }
+ }
+ setSelectedScenes(new Set());
+ setIsSelectMode(false);
+ };
+
+ return (
+ <Sheet>
+ <SheetTrigger asChild>{children}</SheetTrigger>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>
+ {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"}
+ </SheetTitle>
+ <SheetDescription>
+ {isSelectMode
+ ? "Select scenes to delete"
+ : "Switch between scenes in your project"}
+ </SheetDescription>
+ </SheetHeader>
+ <div className="py-4 flex flex-col gap-4">
+ <div className="flex items-center gap-2">
+ <Button
+ className="rounded-md"
+ variant={isSelectMode ? "default" : "outline"}
+ size="sm"
+ onClick={handleSelectMode}
+ >
+ <ListCheck />
+ {isSelectMode ? "Cancel" : "Select"}
+ </Button>
+ {isSelectMode && (
+ <DeleteDialog
+ count={selectedScenes.size}
+ onDelete={handleDeleteSelected}
+ disabled={Array.from(selectedScenes).some(
+ (id) => scenes.find((s) => s.id === id)?.isMain
+ )}
+ >
+ <Button className="rounded-md" variant="destructive" size="sm">
+ <Trash2 />
+ Delete ({selectedScenes.size})
+ </Button>
+ </DeleteDialog>
+ )}
+ </div>
+ {scenes.length === 0 ? (
+ <div className="text-sm text-muted-foreground">
+ No scenes available
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {scenes.map((scene) => (
+ <Button
+ key={scene.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-between font-normal",
+ currentScene?.id === scene.id &&
+ !isSelectMode &&
+ "border-primary !text-primary",
+ isSelectMode &&
+ selectedScenes.has(scene.id) &&
+ "bg-accent border-foreground/30"
+ )}
+ onClick={() => handleSceneSwitch(scene.id)}
+ >
+ <span>{scene.name}</span>
+ <div className="flex items-center gap-2">
+ {((isSelectMode && selectedScenes.has(scene.id)) ||
+ (!isSelectMode && currentScene?.id === scene.id)) && (
+ <Check className="h-4 w-4" />
+ )}
+ </div>
+ </Button>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ );
+}
+
+function DeleteDialog({
+ count,
+ onDelete,
+ disabled,
+ children,
+}: {
+ count: number;
+ onDelete: () => void;
+ disabled?: boolean;
+ children: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const handleDelete = () => {
+ onDelete();
+ setOpen(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>{children}</DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Scenes</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete {count} scene
+ {count === 1 ? "" : "s"}? This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={disabled}
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index c994d707..4a59fd21 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -57,6 +57,9 @@ import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
+import { TimelineCacheIndicator } from "./timeline-cache-indicator";
+import { TimelineMarker } from "./timeline-marker";
+import { useFrameCache } from "@/hooks/use-frame-cache";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -67,6 +70,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { TimelineToolbar } from "./timeline-toolbar";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -85,6 +89,7 @@ export function Timeline() {
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
+ const { getRenderStatus } = useFrameCache();
const [isDragOver, setIsDragOver] = useState(false);
const { addElementToNewTrack } = useTimelineStore();
const dragCounterRef = useRef(0);
@@ -658,7 +663,21 @@ export function Timeline() {
onClick={handleTimelineContentClick}
data-ruler-area
>
- <ScrollArea className="w-full" ref={rulerScrollRef}>
+ <ScrollArea
+ className="w-full"
+ ref={rulerScrollRef}
+ onScroll={(e) => {
+ if (isUpdatingRef.current) return;
+ isUpdatingRef.current = true;
+ const tracksViewport = tracksScrollRef.current;
+ if (tracksViewport) {
+ tracksViewport.scrollLeft = (
+ e.currentTarget as HTMLDivElement
+ ).scrollLeft;
+ }
+ isUpdatingRef.current = false;
+ }}
+ >
<div
ref={rulerRef}
className="relative h-10 select-none cursor-default"
@@ -667,6 +686,14 @@ export function Timeline() {
}}
onMouseDown={handleRulerMouseDown}
>
+ <TimelineCacheIndicator
+ duration={duration}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ mediaFiles={mediaFiles}
+ activeProject={activeProject}
+ getRenderStatus={getRenderStatus}
+ />
{/* Time markers */}
{(() => {
// Calculate appropriate time interval based on zoom level
@@ -693,55 +720,13 @@ export function Timeline() {
time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
return (
- <div
+ <TimelineMarker
key={i}
- className={`absolute top-0 h-4 ${
- isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- }`}
- style={{
- left: `${
- time *
- TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
- zoomLevel
- }px`,
- }}
- >
- <span
- className={`absolute top-1 left-1 text-[0.6rem] ${
- isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- }`}
- >
- {(() => {
- const formatTime = (seconds: number) => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes
- .toString()
- .padStart(2, "0")}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (minutes > 0) {
- return `${minutes}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (interval >= 1) {
- return `${Math.floor(secs)}s`;
- }
- return `${secs.toFixed(1)}s`;
- };
- return formatTime(time);
- })()}
- </span>
- </div>
+ time={time}
+ zoomLevel={zoomLevel}
+ interval={interval}
+ isMainMarker={isMainMarker}
+ />
);
}).filter(Boolean);
})()}
@@ -836,7 +821,21 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
- <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
+ <ScrollArea
+ className="w-full h-full"
+ ref={tracksScrollRef}
+ onScroll={(e) => {
+ if (isUpdatingRef.current) return;
+ isUpdatingRef.current = true;
+ const rulerViewport = rulerScrollRef.current;
+ if (rulerViewport) {
+ rulerViewport.scrollLeft = (
+ e.currentTarget as HTMLDivElement
+ ).scrollLeft;
+ }
+ isUpdatingRef.current = false;
+ }}
+ >
<div
className="relative flex-1"
style={{
@@ -924,383 +923,3 @@ function TrackIcon({ track }: { track: TimelineTrack }) {
</>
);
}
-
-function TimelineToolbar({
- zoomLevel,
- setZoomLevel,
-}: {
- zoomLevel: number;
- setZoomLevel: (zoom: number) => void;
-}) {
- const {
- tracks,
- addTrack,
- addElementToTrack,
- removeElementFromTrack,
- removeElementFromTrackWithRipple,
- selectedElements,
- clearSelectedElements,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- snappingEnabled,
- toggleSnapping,
- rippleEditingEnabled,
- toggleRippleEditing,
- } = useTimelineStore();
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
- const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
-
- const handleSplitSelected = () => {
- if (selectedElements.length === 0) return;
- let splitCount = 0;
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (element && track) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newElementId = splitElement(trackId, elementId, currentTime);
- if (newElementId) splitCount++;
- }
- }
- });
- if (splitCount === 0) {
- toast.error("Playhead must be within selected elements to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) return;
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
-
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((el) => el.id === elementId);
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
- const { id, ...elementWithoutId } = element;
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
- }
- });
- clearSelectedElements();
- };
-
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
-
- const handleSplitAndKeepLeft = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepLeft(trackId, elementId, currentTime);
- };
-
- const handleSplitAndKeepRight = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepRight(trackId, elementId, currentTime);
- };
-
- const handleSeparateAudio = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one media element to separate audio");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- if (!track || track.type !== "media") {
- toast.error("Select a media element to separate audio");
- return;
- }
- separateAudio(trackId, elementId);
- };
-
- const handleDeleteSelected = () => {
- if (selectedElements.length === 0) return;
- selectedElements.forEach(({ trackId, elementId }) => {
- if (rippleEditingEnabled) {
- removeElementFromTrackWithRipple(trackId, elementId);
- } else {
- removeElementFromTrack(trackId, elementId);
- }
- });
- clearSelectedElements();
- };
-
- const handleZoomIn = () => {
- setZoomLevel(Math.min(4, zoomLevel + 0.25));
- };
-
- const handleZoomOut = () => {
- setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
- };
-
- const handleZoomSliderChange = (values: number[]) => {
- setZoomLevel(values[0]);
- };
-
- const handleToggleBookmark = async () => {
- await toggleBookmark(currentTime);
- };
-
- const currentBookmarked = isBookmarked(currentTime);
- return (
- <div className="border-b flex items-center justify-between px-2 py-1">
- <div className="flex items-center gap-1 w-full">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={() => seek(0)}
- className="mr-2"
- >
- <SkipBack className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Return to Start (Home / Enter)</TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- {/* Time Display */}
- <div className="flex flex-row items-center justify-center px-2">
- <EditableTimecode
- time={currentTime}
- duration={duration}
- format="HH:MM:SS:FF"
- fps={activeProject?.fps ?? DEFAULT_FPS}
- onTimeChange={seek}
- className="text-center"
- />
- <div className="text-xs text-muted-foreground font-mono px-2">
- /
- </div>
- <div className="text-xs text-muted-foreground font-mono text-center">
- {formatTimeCode(duration, "HH:MM:SS:FF")}
- </div>
- </div>
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("media");
- addElementToTrack(trackId, {
- type: "media",
- mediaId: "test",
- name: "Test Clip",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>Add a test clip to try playback</TooltipContent>
- </Tooltip>
- </>
- )}
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSplitSelected}>
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split element (Ctrl+S)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSeparateAudio}>
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleFreezeSelected}>
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleDeleteSelected}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete element (Delete)</TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleToggleBookmark}>
- <Bookmark
- className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleSnapping}>
- {snappingEnabled ? (
- <Magnet className="h-4 w-4 text-primary" />
- ) : (
- <Magnet className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>Auto snapping</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleRippleEditing}>
- <Link
- className={`h-4 w-4 ${
- rippleEditingEnabled ? "text-primary" : ""
- }`}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {rippleEditingEnabled
- ? "Disable Ripple Editing"
- : "Enable Ripple Editing"}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
-
- <div className="h-6 w-px bg-border mx-1" />
- <div className="flex items-center gap-1">
- <Button variant="text" size="icon" onClick={handleZoomOut}>
- <ZoomOut className="h-4 w-4" />
- </Button>
- <Slider
- className="w-24"
- value={[zoomLevel]}
- onValueChange={handleZoomSliderChange}
- min={0.25}
- max={4}
- step={0.25}
- />
- <Button variant="text" size="icon" onClick={handleZoomIn}>
- <ZoomIn className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
new file mode 100644
index 00000000..7def2985
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { TimelineTrack } from "@/types/timeline";
+import { MediaFile } from "@/types/media";
+import { TProject } from "@/types/project";
+import { useSceneStore } from "@/stores/scene-store";
+
+interface CacheSegment {
+ startTime: number;
+ endTime: number;
+ cached: boolean;
+}
+
+interface TimelineCacheIndicatorProps {
+ duration: number;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ mediaFiles: MediaFile[];
+ activeProject: TProject | null;
+ getRenderStatus: (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ sceneId?: string
+ ) => "cached" | "not-cached";
+}
+
+export function TimelineCacheIndicator({
+ duration,
+ zoomLevel,
+ tracks,
+ mediaFiles,
+ activeProject,
+ getRenderStatus,
+}: TimelineCacheIndicatorProps) {
+ const { currentScene } = useSceneStore();
+
+ // Calculate cache segments by sampling the timeline
+ const calculateCacheSegments = (): CacheSegment[] => {
+ const segments: CacheSegment[] = [];
+ const sampleRate = 10; // Sample every 0.1 seconds
+ const totalSamples = Math.ceil(duration * sampleRate);
+
+ if (totalSamples === 0) {
+ return [{ startTime: 0, endTime: duration, cached: false }];
+ }
+
+ let currentSegment: CacheSegment | null = null;
+
+ for (let i = 0; i <= totalSamples; i++) {
+ const time = i / sampleRate;
+ const cached =
+ getRenderStatus(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ ) === "cached";
+
+ if (!currentSegment) {
+ // Start first segment
+ currentSegment = {
+ startTime: time,
+ endTime: time,
+ cached,
+ };
+ } else if (currentSegment.cached === cached) {
+ // Extend current segment
+ currentSegment.endTime = time;
+ } else {
+ // Finish current segment and start new one
+ segments.push(currentSegment);
+ currentSegment = {
+ startTime: time,
+ endTime: time,
+ cached,
+ };
+ }
+ }
+
+ // Add the last segment
+ if (currentSegment) {
+ currentSegment.endTime = duration;
+ segments.push(currentSegment);
+ }
+
+ return segments;
+ };
+
+ const cacheSegments = calculateCacheSegments();
+
+ return (
+ <div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none">
+ {cacheSegments.map((segment, index) => {
+ const startX =
+ segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const endX =
+ segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const width = endX - startX;
+
+ return (
+ <div
+ key={index}
+ className={cn(
+ "absolute top-0 h-px",
+ segment.cached ? "bg-primary" : "bg-transparent"
+ )}
+ style={{
+ left: `${startX}px`,
+ width: `${width}px`,
+ }}
+ title={
+ segment.cached
+ ? "Cached (fast playback)"
+ : "Not cached (will render)"
+ }
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index 677b9c61..ca47d3ca 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform";
import { toast } from "sonner";
-import { TimelineElementProps } from "@/types/timeline";
+import { TimelineElementProps, MediaElement } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
@@ -42,16 +42,17 @@ export function TimelineElement({
}: TimelineElementProps) {
const { mediaFiles } = useMediaStore();
const {
- removeElementFromTrack,
- removeElementFromTrackWithRipple,
dragState,
- splitElement,
- addElementToTrack,
- replaceElementMedia,
- rippleEditingEnabled,
- toggleElementHidden,
- toggleElementMuted,
copySelected,
+ selectedElements,
+ deleteSelected,
+ splitSelected,
+ toggleSelectedHidden,
+ toggleSelectedMuted,
+ duplicateElement,
+ revealElementInMedia,
+ replaceElementWithFile,
+ getContextMenuState,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -68,7 +69,12 @@ export function TimelineElement({
zoomLevel,
});
- const { requestRevealMedia } = useMediaPanelStore.getState();
+ const {
+ isMultipleSelected,
+ isCurrentElementSelected,
+ hasAudioElements,
+ canSplitSelected,
+ } = getContextMenuState(track.id, element.id);
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
@@ -77,44 +83,26 @@ export function TimelineElement({
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
- // Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
- // Element should always be positioned at startTime - trimStart only affects content, not position
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleElementSplitContext = (e: React.MouseEvent) => {
e.stopPropagation();
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
+ splitSelected(
+ currentTime,
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
};
const handleElementDuplicateContext = (e: React.MouseEvent) => {
e.stopPropagation();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
+ duplicateElement(track.id, element.id);
};
const handleElementCopyContext = (e: React.MouseEvent) => {
@@ -124,49 +112,38 @@ export function TimelineElement({
const handleElementDeleteContext = (e: React.MouseEvent) => {
e.stopPropagation();
- if (rippleEditingEnabled) {
- removeElementFromTrackWithRipple(track.id, element.id);
- } else {
- removeElementFromTrack(track.id, element.id);
- }
+ deleteSelected(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
};
const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation();
+
if (hasAudio && element.type === "media") {
- toggleElementMuted(track.id, element.id);
- return;
+ toggleSelectedMuted(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
+ } else {
+ toggleSelectedHidden(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
}
- toggleElementHidden(track.id, element.id);
};
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
- if (element.type !== "media") {
- toast.error("Replace is only available for media clips");
- return;
- }
- // Create a file input to select replacement media
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
- if (!file) return;
-
- try {
- const result = await replaceElementMedia(track.id, element.id, file);
- if (result.success) {
- toast.success("Clip replaced successfully");
- } else {
- toast.error(result.error || "Failed to replace clip");
- }
- } catch (error) {
- console.error("Unexpected error replacing clip:", error);
- toast.error(
- `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
- );
+ if (file) {
+ await replaceElementWithFile(track.id, element.id, file);
}
};
input.click();
@@ -174,12 +151,7 @@ export function TimelineElement({
const handleRevealInMedia = (e: React.MouseEvent) => {
e.stopPropagation();
- if (element.type !== "media") {
- toast.error("Reveal is only available for media clips");
- return;
- }
-
- requestRevealMedia(element.mediaId);
+ revealElementInMedia(element.id);
};
const renderElementContent = () => {
@@ -191,7 +163,6 @@ export function TimelineElement({
);
}
- // Render media element ->
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) {
return (
@@ -201,20 +172,15 @@ export function TimelineElement({
);
}
- const TILE_ASPECT_RATIO = 16 / 9;
-
if (
mediaItem.type === "image" ||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
) {
- // Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight;
- const tileWidth = tileHeight * TILE_ASPECT_RATIO;
+ const tileWidth = trackHeight * (16 / 9);
const imageUrl =
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
- const isImage = mediaItem.type === "image";
return (
<div className="w-full h-full flex items-center justify-center">
@@ -228,18 +194,17 @@ export function TimelineElement({
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${tileHeight}px`,
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
- aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`}
+ aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
/>
</div>
</div>
);
}
- // Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
@@ -267,7 +232,7 @@ export function TimelineElement({
}
};
- const isMuted = element.type === "media" ? element.muted === true : false;
+ const isMuted = element.type === "media" && element.muted;
return (
<ContextMenu>
@@ -332,16 +297,33 @@ export function TimelineElement({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200">
- <ContextMenuItem onClick={handleElementSplitContext}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at playhead
- </ContextMenuItem>
+ {(!isMultipleSelected ||
+ (isMultipleSelected &&
+ isCurrentElementSelected &&
+ canSplitSelected)) && (
+ <ContextMenuItem onClick={handleElementSplitContext}>
+ <Scissors className="h-4 w-4 mr-2" />
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Split ${selectedElements.length} elements at playhead`
+ : "Split at playhead"}
+ </ContextMenuItem>
+ )}
+
<ContextMenuItem onClick={handleElementCopyContext}>
<Copy className="h-4 w-4 mr-2" />
- Copy element
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Copy ${selectedElements.length} elements`
+ : "Copy element"}
</ContextMenuItem>
+
<ContextMenuItem onClick={handleToggleElementContext}>
- {hasAudio ? (
+ {isMultipleSelected && isCurrentElementSelected ? (
+ hasAudioElements ? (
+ <VolumeX className="h-4 w-4 mr-2" />
+ ) : (
+ <EyeOff className="h-4 w-4 mr-2" />
+ )
+ ) : hasAudio ? (
isMuted ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
@@ -353,21 +335,29 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
- {hasAudio
- ? isMuted
- ? "Unmute"
- : "Mute"
- : element.hidden
- ? "Show"
- : "Hide"}{" "}
- {element.type === "text" ? "text" : "clip"}
+ {isMultipleSelected && isCurrentElementSelected
+ ? hasAudioElements
+ ? `Toggle mute ${selectedElements.length} elements`
+ : `Toggle visibility ${selectedElements.length} elements`
+ : hasAudio
+ ? isMuted
+ ? "Unmute"
+ : "Mute"
+ : element.hidden
+ ? "Show"
+ : "Hide"}{" "}
+ {!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
</span>
</ContextMenuItem>
- <ContextMenuItem onClick={handleElementDuplicateContext}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- {element.type === "media" && (
+
+ {!isMultipleSelected && (
+ <ContextMenuItem onClick={handleElementDuplicateContext}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ )}
+
+ {!isMultipleSelected && element.type === "media" && (
<>
<ContextMenuItem onClick={handleRevealInMedia}>
<Search className="h-4 w-4 mr-2" />
@@ -379,13 +369,17 @@ export function TimelineElement({
</ContextMenuItem>
</>
)}
+
<ContextMenuSeparator />
+
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
- Delete {element.type === "text" ? "text" : "clip"}
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedElements.length} elements`
+ : `Delete ${element.type === "text" ? "text" : "clip"}`}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx
new file mode 100644
index 00000000..1351a457
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+interface TimelineMarkerProps {
+ time: number;
+ zoomLevel: number;
+ interval: number;
+ isMainMarker: boolean;
+}
+
+export function TimelineMarker({
+ time,
+ zoomLevel,
+ interval,
+ isMainMarker,
+}: TimelineMarkerProps) {
+ return (
+ <div
+ className={cn(
+ "absolute top-0 h-4",
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ )}
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ >
+ <span
+ className={cn(
+ "absolute top-1 left-1 text-[0.6rem]",
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ )}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes
+ .toString()
+ .padStart(2, "0")}:${Math.floor(secs)
+ .toString()
+ .padStart(2, "0")}`;
+ }
+ if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs)
+ .toString()
+ .padStart(2, "0")}`;
+ }
+ if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ }
+ return `${secs.toFixed(1)}s`;
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
new file mode 100644
index 00000000..80c6ce08
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -0,0 +1,397 @@
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useSceneStore } from "@/stores/scene-store";
+import { toast } from "sonner";
+import {
+ TooltipProvider,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import {
+ Pause,
+ Play,
+ SkipBack,
+ Bookmark,
+ Magnet,
+ Link,
+ ZoomOut,
+ ZoomIn,
+ Copy,
+ Trash2,
+ Snowflake,
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ SplitSquareHorizontal,
+ Scissors,
+ LayersIcon,
+} from "lucide-react";
+import {
+ SplitButton,
+ SplitButtonLeft,
+ SplitButtonRight,
+ SplitButtonSeparator,
+} from "@/components/ui/split-button";
+import { Slider } from "@/components/ui/slider";
+import { DEFAULT_FPS } from "@/stores/project-store";
+import { formatTimeCode } from "@/lib/time";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { ScenesView } from "../scenes-view";
+
+export function TimelineToolbar({
+ zoomLevel,
+ setZoomLevel,
+}: {
+ zoomLevel: number;
+ setZoomLevel: (zoom: number) => void;
+}) {
+ const {
+ tracks,
+ addTrack,
+ addElementToTrack,
+ selectedElements,
+ clearSelectedElements,
+ deleteSelected,
+ splitSelected,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ snappingEnabled,
+ toggleSnapping,
+ rippleEditingEnabled,
+ toggleRippleEditing,
+ } = useTimelineStore();
+ const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
+ const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
+ const { scenes, currentScene } = useSceneStore();
+
+ const handleSplitSelected = () => {
+ splitSelected(currentTime);
+ };
+
+ const handleDuplicateSelected = () => {
+ if (selectedElements.length === 0) return;
+ const canDuplicate = selectedElements.length === 1;
+ if (!canDuplicate) return;
+
+ selectedElements.forEach(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((el) => el.id === elementId);
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
+ });
+ }
+ });
+ clearSelectedElements();
+ };
+
+ const handleFreezeSelected = () => {
+ toast.info("Freeze frame functionality coming soon!");
+ };
+
+ const handleSplitAndKeepLeft = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
+ splitAndKeepLeft(trackId, elementId, currentTime);
+ };
+
+ const handleSplitAndKeepRight = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
+ splitAndKeepRight(trackId, elementId, currentTime);
+ };
+
+ const handleSeparateAudio = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one media element to separate audio");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ if (!track || track.type !== "media") {
+ toast.error("Select a media element to separate audio");
+ return;
+ }
+ separateAudio(trackId, elementId);
+ };
+
+ const handleDeleteSelected = () => {
+ deleteSelected();
+ };
+
+ const handleZoomIn = () => {
+ setZoomLevel(Math.min(4, zoomLevel + 0.25));
+ };
+
+ const handleZoomOut = () => {
+ setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
+ };
+
+ const handleZoomSliderChange = (values: number[]) => {
+ setZoomLevel(values[0]);
+ };
+
+ const handleToggleBookmark = async () => {
+ await toggleBookmark(currentTime);
+ };
+
+ const currentBookmarked = isBookmarked(currentTime);
+ return (
+ <div className="flex items-center justify-between px-2 py-1 border-b h-10">
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggle}>
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={() => seek(0)}>
+ <SkipBack className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Return to Start (Home / Enter)</TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Time Display */}
+ <div className="flex flex-row items-center justify-center px-2">
+ <EditableTimecode
+ time={currentTime}
+ duration={duration}
+ format="HH:MM:SS:FF"
+ fps={activeProject?.fps ?? DEFAULT_FPS}
+ onTimeChange={seek}
+ className="text-center"
+ />
+ <div className="text-xs text-muted-foreground font-mono px-2">
+ /
+ </div>
+ <div className="text-xs text-muted-foreground font-mono text-center">
+ {formatTimeCode(duration, "HH:MM:SS:FF")}
+ </div>
+ </div>
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("media");
+ addElementToTrack(trackId, {
+ type: "media",
+ mediaId: "test",
+ name: "Test Clip",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSplitSelected}>
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split element (Ctrl+S)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepLeft}
+ >
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepRight}
+ >
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSeparateAudio}>
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleFreezeSelected}>
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleDeleteSelected}>
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete element (Delete)</TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleToggleBookmark}>
+ <Bookmark
+ className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <div>
+ <SplitButton className="border border-foreground/10">
+ <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
+ <SplitButtonSeparator />
+ <ScenesView>
+ <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
+ <LayersIcon />
+ </SplitButtonRight>
+ </ScenesView>
+ </SplitButton>
+ </div>
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleSnapping}>
+ {snappingEnabled ? (
+ <Magnet className="h-4 w-4 text-primary" />
+ ) : (
+ <Magnet className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Auto snapping</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleRippleEditing}>
+ <Link
+ className={`h-4 w-4 ${
+ rippleEditingEnabled ? "text-primary" : ""
+ }`}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {rippleEditingEnabled
+ ? "Disable Ripple Editing"
+ : "Enable Ripple Editing"}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ <div className="h-6 w-px bg-border mx-1" />
+ <div className="flex items-center gap-1">
+ <Button variant="text" size="icon" onClick={handleZoomOut}>
+ <ZoomOut className="h-4 w-4" />
+ </Button>
+ <Slider
+ className="w-24"
+ value={[zoomLevel]}
+ onValueChange={handleZoomSliderChange}
+ min={0.25}
+ max={4}
+ step={0.25}
+ />
+ <Button variant="text" size="icon" onClick={handleZoomIn}>
+ <ZoomIn className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 42c78aa4..7cfde931 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -1147,7 +1147,7 @@ export function TimelineTrackContent({
const handleElementSplit = () => {
const { currentTime } = usePlaybackStore();
- const { splitElement } = useTimelineStore();
+ const { splitSelected } = useTimelineStore();
const splitTime = currentTime;
const effectiveStart = element.startTime;
const effectiveEnd =
@@ -1155,14 +1155,7 @@ export function TimelineTrackContent({
(element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- const secondElementId = splitElement(
- track.id,
- element.id,
- splitTime
- );
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
+ splitSelected(splitTime, track.id, element.id);
} else {
toast.error("Playhead must be within element to split");
}
@@ -1182,8 +1175,8 @@ export function TimelineTrackContent({
};
const handleElementDelete = () => {
- const { removeElementFromTrack } = useTimelineStore.getState();
- removeElementFromTrack(track.id, element.id);
+ const { deleteSelected } = useTimelineStore.getState();
+ deleteSelected(track.id, element.id);
};
return (
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 0087502e..176452cd 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -2,28 +2,11 @@
import { motion } from "motion/react";
import Link from "next/link";
-import { useEffect, useState } from "react";
import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
-import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
export function Footer() {
- const [star, setStar] = useState<string>();
-
- useEffect(() => {
- const fetchStars = async () => {
- try {
- const data = await getStars();
- setStar(data);
- } catch (err) {
- console.error("Failed to fetch GitHub stars", err);
- }
- };
-
- fetchStars();
- }, []);
-
return (
<motion.footer
className="bg-background border-t"
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx
index dd6a500b..689c3ee3 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -111,7 +111,7 @@ export function KeyboardShortcutsHelp() {
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
- <DialogHeader className="flex-shrink-0 p-6 pb-4">
+ <DialogHeader className="flex-shrink-0 p-6 pb-0">
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5" />
Keyboard Shortcuts
@@ -123,7 +123,7 @@ export function KeyboardShortcutsHelp() {
</DialogHeader>
<div className="overflow-y-auto flex-grow scrollbar-thin">
- <div className="space-y-6 p-6">
+ <div className="space-y-6 p-6 pt-2">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
<h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts
deleted file mode 100644
index 2d7c684c..00000000
--- a/apps/web/src/components/providers/global-prefetcher.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useSoundsStore } from "@/stores/sounds-store";
-
-export function useGlobalPrefetcher() {
- const {
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- } = useSoundsStore();
-
- useEffect(() => {
- if (hasLoaded) return;
-
- let ignore = false;
-
- const prefetchTopSounds = async () => {
- try {
- if (!ignore) {
- setLoading(true);
- setError(null);
- }
-
- const response = await fetch(
- "/api/sounds/search?page_size=50&sort=downloads"
- );
-
- if (!ignore) {
- if (!response.ok) {
- throw new Error(`Failed to fetch: ${response.status}`);
- }
-
- const data = await response.json();
- setTopSoundEffects(data.results);
- setHasLoaded(true);
-
- // Set pagination state for top sounds
- setCurrentPage(1);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- }
- } catch (error) {
- if (!ignore) {
- console.error("Failed to prefetch top sounds:", error);
- setError(
- error instanceof Error ? error.message : "Failed to load sounds"
- );
- }
- } finally {
- if (!ignore) {
- setLoading(false);
- }
- }
- };
-
- const timeoutId = setTimeout(prefetchTopSounds, 100);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- ]);
-}
diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
new file mode 100644
index 00000000..b984a7f4
--- /dev/null
+++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePathname } from "next/navigation";
+import { storageService } from "@/lib/storage/storage-service";
+import { TProject, Scene } from "@/types/project";
+import { generateUUID } from "@/lib/utils";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Progress } from "@/components/ui/progress";
+import { createMainScene } from "@/stores/project-store";
+
+interface MigrationProgress {
+ current: number;
+ total: number;
+ currentProjectName: string;
+}
+
+export function ScenesMigrator({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const [isMigrating, setIsMigrating] = useState(false);
+ const [progress, setProgress] = useState<MigrationProgress>({
+ current: 0,
+ total: 0,
+ currentProjectName: "",
+ });
+
+ const shouldCheckMigration =
+ pathname.startsWith("/editor") || pathname.startsWith("/projects");
+
+ useEffect(() => {
+ if (!shouldCheckMigration) return;
+
+ checkAndMigrateProjects();
+ }, [shouldCheckMigration]);
+
+ const checkAndMigrateProjects = async () => {
+ try {
+ const projects = await storageService.loadAllProjects();
+ const legacyProjects = projects.filter(
+ (project) => !project.scenes || project.scenes.length === 0
+ );
+
+ if (legacyProjects.length === 0) {
+ // No migration needed
+ return;
+ }
+
+ setIsMigrating(true);
+ setProgress({
+ current: 0,
+ total: legacyProjects.length,
+ currentProjectName: "",
+ });
+
+ // Migrate each legacy project
+ for (let i = 0; i < legacyProjects.length; i++) {
+ const project = legacyProjects[i];
+
+ setProgress({
+ current: i,
+ total: legacyProjects.length,
+ currentProjectName: project.name,
+ });
+
+ await migrateLegacyProject(project);
+ }
+
+ setProgress({
+ current: legacyProjects.length,
+ total: legacyProjects.length,
+ currentProjectName: "Complete!",
+ });
+
+ setTimeout(() => {
+ setIsMigrating(false);
+ }, 1000);
+ } catch (error) {
+ console.error("Migration failed:", error);
+ setIsMigrating(false);
+ }
+ };
+
+ const migrateLegacyProject = async (project: TProject) => {
+ try {
+ const mainScene: Scene = {
+ id: generateUUID(),
+ name: "Main Scene",
+ isMain: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ const migratedProject: TProject = {
+ ...project,
+ scenes: [mainScene],
+ currentSceneId: mainScene.id,
+ updatedAt: new Date(),
+ };
+
+ // Load existing timeline data (legacy format)
+ const legacyTimeline = await storageService.loadTimeline({
+ projectId: project.id,
+ });
+
+ await storageService.saveProject({ project: migratedProject });
+
+ // If timeline data, migrate it to the main scene
+ if (legacyTimeline && legacyTimeline.length > 0) {
+ await storageService.saveTimeline({
+ projectId: project.id,
+ tracks: legacyTimeline,
+ sceneId: mainScene.id,
+ });
+ }
+
+ // Clean up legacy timeline storage
+ await storageService.deleteProjectTimeline({ projectId: project.id });
+ } catch (error) {
+ console.error(`Failed to migrate project ${project.name}:`, error);
+ throw error;
+ }
+ };
+
+ if (!shouldCheckMigration) {
+ return children;
+ }
+
+ if (isMigrating) {
+ const progressPercent =
+ progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
+
+ return (
+ <Dialog open={true}>
+ <DialogContent
+ className="sm:max-w-md"
+ onPointerDownOutside={(e) => e.preventDefault()}
+ >
+ <DialogHeader>
+ <DialogTitle>Updating Projects</DialogTitle>
+ <DialogDescription>
+ We're adding scene support to your projects. This will only take a
+ moment.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>Progress</span>
+ <span>
+ {progress.current} of {progress.total}
+ </span>
+ </div>
+ <Progress value={progressPercent} className="w-full" />
+ </div>
+
+ {progress.currentProjectName && (
+ <div className="text-sm text-muted-foreground">
+ {progress.current < progress.total
+ ? `Updating: ${progress.currentProjectName}`
+ : progress.currentProjectName}
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ return children;
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 0e07ccb8..56a10f44 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -16,9 +16,9 @@ const buttonVariants = cva(
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
- "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
+ "bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
outline:
- "border border-input bg-background shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground",
+ "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input",
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index ad7e825b..42d0f80d 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
- "fixed inset-0 z-150 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ "fixed inset-0 z-250 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
- "fixed left-[50%] top-[50%] p-6 z-150 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
+ "fixed left-[50%] top-[50%] p-6 z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
className
)}
onCloseAutoFocus={(e) => {
diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx
index c238b33c..9f5c7887 100644
--- a/apps/web/src/components/ui/draggable-item.tsx
+++ b/apps/web/src/components/ui/draggable-item.tsx
@@ -218,7 +218,7 @@ function PlusButton({
<Button
size="icon"
className={cn(
- "absolute bottom-2 right-2 size-4 bg-background hover:bg-panel text-foreground",
+ "absolute bottom-2 right-2 size-5 bg-background hover:bg-panel text-foreground",
className
)}
onClick={(e) => {
@@ -228,7 +228,7 @@ function PlusButton({
}}
title={tooltipText}
>
- <Plus className="size-3!" />
+ <Plus className="size-4!" />
</Button>
);
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx
index ea225c14..cae9f8c9 100644
--- a/apps/web/src/components/ui/input-with-back.tsx
+++ b/apps/web/src/components/ui/input-with-back.tsx
@@ -12,6 +12,7 @@ interface InputWithBackProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
+ disableAnimation?: boolean;
}
export function InputWithBack({
@@ -20,12 +21,13 @@ export function InputWithBack({
placeholder = "Search anything",
value,
onChange,
+ disableAnimation = false,
}: InputWithBackProps) {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [buttonOffset, setButtonOffset] = useState(-60);
const smoothTransition = {
- duration: 0.35,
+ duration: disableAnimation ? 0 : 0.35,
ease: [0.25, 0.1, 0.25, 1] as const,
};
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 47782a7f..406ff8eb 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
- "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 2c5522c1..f97bd438 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
- "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ "fixed inset-0 z-200 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
- "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
@@ -62,10 +62,14 @@ const SheetContent = React.forwardRef<
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
{...props}
>
- <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
- <X className="h-4 w-4" />
+ <SheetPrimitive.Close className="absolute cursor-pointer right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+ <X className="size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx
new file mode 100644
index 00000000..ca3b518c
--- /dev/null
+++ b/apps/web/src/components/ui/split-button.tsx
@@ -0,0 +1,90 @@
+import { Button, ButtonProps } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { ReactNode, forwardRef } from "react";
+import { cn } from "@/lib/utils";
+
+interface SplitButtonProps {
+ children: ReactNode;
+ className?: string;
+}
+
+interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
+ children: ReactNode;
+}
+
+const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
+ ({ children, className, ...props }, ref) => {
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "inline-flex rounded-full h-7 border border-input bg-panel-accent overflow-hidden",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+ }
+);
+SplitButton.displayName = "SplitButton";
+
+const SplitButtonSide = forwardRef<
+ HTMLButtonElement,
+ SplitButtonSideProps & { paddingClass: string }
+>(({ children, className, paddingClass, onClick, ...props }, ref) => {
+ return (
+ <Button
+ ref={ref}
+ variant="text"
+ className={cn(
+ "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors disabled:text-muted-foreground",
+ onClick
+ ? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer"
+ : "cursor-default select-text",
+ paddingClass,
+ className
+ )}
+ onClick={onClick}
+ {...props}
+ >
+ {typeof children === "string" ? (
+ <span className="font-normal cursor-text">{children}</span>
+ ) : (
+ children
+ )}
+ </Button>
+ );
+});
+SplitButtonSide.displayName = "SplitButtonSide";
+
+const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
+ }
+);
+SplitButtonLeft.displayName = "SplitButtonLeft";
+
+const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
+ }
+);
+SplitButtonRight.displayName = "SplitButtonRight";
+
+const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
+ ({ className, ...props }, ref) => {
+ return (
+ <Separator
+ ref={ref}
+ orientation="vertical"
+ className={cn("h-full bg-foreground/15", className)}
+ {...props}
+ />
+ );
+ }
+);
+SplitButtonSeparator.displayName = "SplitButtonSeparator";
+
+export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx
index 6a7677b4..50c7e7cc 100644
--- a/apps/web/src/data/colors/syntax-ui.tsx
+++ b/apps/web/src/data/colors/syntax-ui.tsx
@@ -1,32 +1,32 @@
-// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
-
-export const syntaxUIGradients = [
- // Cyan to Blue gradients
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
- "linear-gradient(to right, #bfdbfe, #a5f3fc)",
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
-
- // Purple gradients
- "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
- "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
-
- // Blue gradients
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
-
- // Green gradients
- "linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
- "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
-
- // Red gradient
- "linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
-
- // Yellow/Orange gradient
- "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
-
- // Pink gradient
- "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
-
- // Neon radial gradient
- "radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
-];
+// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
+
+export const syntaxUIGradients = [
+ // Cyan to Blue gradients
+ "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
+ "linear-gradient(to right, #bfdbfe, #a5f3fc)",
+ "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
+
+ // Purple gradients
+ "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
+ "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
+
+ // Blue gradients
+ "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
+ "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
+
+ // Green gradients
+ "linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
+ "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
+
+ // Red gradient
+ "linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
+
+ // Yellow/Orange gradient
+ "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
+
+ // Pink gradient
+ "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
+
+ // Neon radial gradient
+ "radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
+];
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
index 974f9db5..9287d2e0 100644
--- a/apps/web/src/hooks/use-editor-actions.ts
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -13,8 +13,8 @@ export function useEditorActions() {
selectedElements,
clearSelectedElements,
setSelectedElements,
- removeElementFromTrack,
- splitElement,
+ deleteSelected,
+ splitSelected,
addElementToTrack,
snappingEnabled,
toggleSnapping,
@@ -135,7 +135,7 @@ export function useEditorActions() {
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitElement(trackId, elementId, currentTime);
+ splitSelected(currentTime, trackId, elementId);
} else {
toast.error("Playhead must be within selected element");
}
@@ -150,12 +150,7 @@ export function useEditorActions() {
if (selectedElements.length === 0) {
return;
}
- selectedElements.forEach(
- ({ trackId, elementId }: { trackId: string; elementId: string }) => {
- removeElementFromTrack(trackId, elementId);
- }
- );
- clearSelectedElements();
+ deleteSelected();
},
undefined
);
diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts
new file mode 100644
index 00000000..cce187f1
--- /dev/null
+++ b/apps/web/src/hooks/use-frame-cache.ts
@@ -0,0 +1,348 @@
+import { useRef, useCallback } from "react";
+import {
+ TimelineTrack,
+ TimelineElement,
+ MediaElement,
+ TextElement,
+} from "@/types/timeline";
+import { MediaFile } from "@/types/media";
+import { TProject } from "@/types/project";
+
+interface CachedFrame {
+ imageData: ImageData;
+ timelineHash: string;
+ timestamp: number;
+}
+
+interface FrameCacheOptions {
+ maxCacheSize?: number; // Maximum number of cached frames
+ cacheResolution?: number; // Frames per second to cache at
+}
+
+// Shared singleton cache across hook instances (HMR-safe)
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const __frameCacheGlobal: any = globalThis as any;
+const __sharedFrameCache: Map<number, CachedFrame> =
+ __frameCacheGlobal.__sharedFrameCache ?? new Map<number, CachedFrame>();
+__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache;
+
+export function useFrameCache(options: FrameCacheOptions = {}) {
+ const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps
+
+ const frameCacheRef = useRef(__sharedFrameCache);
+
+ // Generate a hash of the timeline state that affects rendering
+ const getTimelineHash = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ sceneId?: string
+ ): string => {
+ // Get elements that are active at this time
+ const activeElements: Array<{
+ id: string;
+ type: string;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ mediaId?: string;
+ // Text-specific properties
+ content?: string;
+ fontSize?: number;
+ fontFamily?: string;
+ color?: string;
+ backgroundColor?: string;
+ x?: number;
+ y?: number;
+ rotation?: number;
+ opacity?: number;
+ }> = [];
+
+ for (const track of tracks) {
+ if (track.muted) continue;
+
+ for (const element of track.elements) {
+ // Check if element has hidden property (some elements might not have it)
+ const isHidden = "hidden" in element ? element.hidden : false;
+ if (isHidden) continue;
+
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (time >= elementStart && time < elementEnd) {
+ if (element.type === "media") {
+ const mediaElement = element as MediaElement;
+ activeElements.push({
+ id: element.id,
+ type: element.type,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ mediaId: mediaElement.mediaId,
+ });
+ } else if (element.type === "text") {
+ const textElement = element as TextElement;
+ activeElements.push({
+ id: element.id,
+ type: element.type,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ content: textElement.content,
+ fontSize: textElement.fontSize,
+ fontFamily: textElement.fontFamily,
+ color: textElement.color,
+ backgroundColor: textElement.backgroundColor,
+ x: textElement.x,
+ y: textElement.y,
+ rotation: textElement.rotation,
+ opacity: textElement.opacity,
+ });
+ }
+ }
+ }
+ }
+
+ // Include project settings that affect rendering
+ const projectState = {
+ backgroundColor: activeProject?.backgroundColor,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
+ canvasSize: activeProject?.canvasSize,
+ };
+
+ const hash = {
+ activeElements,
+ projectState,
+ sceneId,
+ time: Math.floor(time * cacheResolution) / cacheResolution,
+ };
+ return JSON.stringify(hash);
+ },
+ [cacheResolution]
+ );
+
+ // Check if a frame is cached and valid
+ const isFrameCached = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ sceneId?: string
+ ): boolean => {
+ const frameKey = Math.floor(time * cacheResolution);
+ const cached = frameCacheRef.current.get(frameKey);
+
+ if (!cached) return false;
+
+ const currentHash = getTimelineHash(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject,
+ sceneId
+ );
+ return cached.timelineHash === currentHash;
+ },
+ [getTimelineHash, cacheResolution]
+ );
+
+ // Get cached frame if available and valid
+ const getCachedFrame = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ sceneId?: string
+ ): ImageData | null => {
+ const frameKey = Math.floor(time * cacheResolution);
+ const cached = frameCacheRef.current.get(frameKey);
+
+ if (!cached) {
+ return null;
+ }
+
+ const currentHash = getTimelineHash(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject,
+ sceneId
+ );
+ console.log(cached.timelineHash === currentHash);
+ if (cached.timelineHash !== currentHash) {
+ // Cache is stale, remove it
+ console.log(
+ "Cache miss - hash mismatch:",
+ JSON.stringify({
+ cachedHash: cached.timelineHash.slice(0, 100),
+ currentHash: currentHash.slice(0, 100),
+ })
+ );
+ frameCacheRef.current.delete(frameKey);
+ return null;
+ }
+
+ return cached.imageData;
+ },
+ [getTimelineHash, cacheResolution]
+ );
+
+ // Cache a rendered frame
+ const cacheFrame = useCallback(
+ (
+ time: number,
+ imageData: ImageData,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ sceneId?: string
+ ): void => {
+ const frameKey = Math.floor(time * cacheResolution);
+ const timelineHash = getTimelineHash(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject,
+ sceneId
+ );
+
+ // Enforce cache size limit (LRU eviction)
+ if (frameCacheRef.current.size >= maxCacheSize) {
+ // Remove oldest entries
+ const entries = Array.from(frameCacheRef.current.entries());
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
+
+ // Remove oldest 20% of entries
+ const toRemove = Math.floor(entries.length * 0.2);
+ for (let i = 0; i < toRemove; i++) {
+ frameCacheRef.current.delete(entries[i][0]);
+ }
+ }
+
+ frameCacheRef.current.set(frameKey, {
+ imageData,
+ timelineHash,
+ timestamp: Date.now(),
+ });
+ },
+ [getTimelineHash, cacheResolution, maxCacheSize]
+ );
+
+ // Clear cache when timeline changes significantly
+ const invalidateCache = useCallback(() => {
+ frameCacheRef.current.clear();
+ }, []);
+
+ // Get render status for timeline indicator
+ const getRenderStatus = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ sceneId?: string
+ ): "cached" | "not-cached" => {
+ return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)
+ ? "cached"
+ : "not-cached";
+ },
+ [isFrameCached]
+ );
+
+ // Pre-render frames around current time
+ const preRenderNearbyFrames = useCallback(
+ async (
+ currentTime: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ renderFunction: (time: number) => Promise<ImageData>,
+ sceneId?: string,
+ range: number = 3 // seconds
+ ) => {
+ const framesToPreRender: number[] = [];
+
+ // Calculate frames to pre-render (around current time)
+ for (
+ let offset = -range;
+ offset <= range;
+ offset += 1 / cacheResolution
+ ) {
+ const time = currentTime + offset;
+ if (time < 0) continue;
+
+ if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) {
+ framesToPreRender.push(time);
+ }
+ }
+
+ // Expand to full 1-second buckets to avoid fragmented tiny cache regions
+ const secondsToPreRender = new Set<number>();
+ for (const t of framesToPreRender) {
+ secondsToPreRender.add(Math.floor(t));
+ }
+
+ const expandedTimes: number[] = [];
+ for (const s of secondsToPreRender) {
+ for (let k = 0; k < cacheResolution; k++) {
+ const t = s + k / cacheResolution;
+ if (t < 0) continue;
+ if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) {
+ expandedTimes.push(t);
+ }
+ }
+ }
+
+ // Sort forward-first near currentTime to improve perceived responsiveness
+ expandedTimes.sort((a, b) => {
+ const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6;
+ const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6;
+ return da - db;
+ });
+
+ // Cap total scheduled renders to avoid jank (e.g., up to 90 frames)
+ const CAP = Math.max(30, Math.min(90, cacheResolution * 3));
+ const toSchedule = expandedTimes.slice(0, CAP);
+
+ // Pre-render during idle time
+ for (const time of toSchedule) {
+ requestIdleCallback(async () => {
+ try {
+ const imageData = await renderFunction(time);
+ cacheFrame(
+ time,
+ imageData,
+ tracks,
+ mediaFiles,
+ activeProject,
+ sceneId
+ );
+ } catch (error) {
+ console.warn(`Pre-render failed for time ${time}:`, error);
+ }
+ });
+ }
+ },
+ [isFrameCached, cacheFrame, cacheResolution]
+ );
+
+ return {
+ isFrameCached,
+ getCachedFrame,
+ cacheFrame,
+ invalidateCache,
+ getRenderStatus,
+ preRenderNearbyFrames,
+ cacheSize: frameCacheRef.current.size,
+ };
+}
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts
index ecbd1232..c27911d2 100644
--- a/apps/web/src/hooks/use-playback-controls.ts
+++ b/apps/web/src/hooks/use-playback-controls.ts
@@ -9,7 +9,7 @@ export const usePlaybackControls = () => {
const {
selectedElements,
tracks,
- splitElement,
+ splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
@@ -37,8 +37,8 @@ export const usePlaybackControls = () => {
return;
}
- splitElement(trackId, elementId, currentTime);
- }, [selectedElements, tracks, currentTime, splitElement]);
+ splitSelected(currentTime, trackId, elementId);
+ }, [selectedElements, tracks, currentTime, splitSelected]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
if (selectedElements.length !== 1) {
diff --git a/apps/web/src/lib/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts
new file mode 100644
index 00000000..7d1cdc05
--- /dev/null
+++ b/apps/web/src/lib/canvas-gradients.ts
@@ -0,0 +1,219 @@
+function splitBackgroundLayers(input: string): string[] {
+ const layers: string[] = [];
+ let depth = 0;
+ let start = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ const ch = input[i] as string;
+ if (ch === "(") depth += 1;
+ else if (ch === ")") depth -= 1;
+ else if (ch === "," && depth === 0) {
+ layers.push(input.slice(start, i).trim());
+ start = i + 1;
+ }
+ }
+ layers.push(input.slice(start).trim());
+ return layers;
+}
+
+function parseColorStop(stop: string): { color: string; position?: number } {
+ const s = stop.trim();
+
+ // Handle functional colors like rgba(), rgb(), hsla(), hsl()
+ const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("];
+ let color = "";
+ let remaining = "";
+
+ for (const fn of colorFunctions) {
+ if (s.startsWith(fn)) {
+ let depth = 0;
+ for (let i = 0; i < s.length; i += 1) {
+ const ch = s[i] as string;
+ if (ch === "(") depth += 1;
+ else if (ch === ")") {
+ depth -= 1;
+ if (depth === 0) {
+ color = s.slice(0, i + 1);
+ remaining = s.slice(i + 1).trim();
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ if (!color) {
+ const parts = s.split(/\s+/);
+ color = parts[0] as string;
+ remaining = parts.slice(1).join(" ");
+ }
+
+ // Convert transparent to transparent white for better blending
+ if (color === "transparent") {
+ color = "rgba(255, 255, 255, 0)";
+ }
+
+ // Parse position if present
+ let position: number | undefined;
+ if (remaining) {
+ const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/);
+ if (posMatch) {
+ position = parseFloat(posMatch[1] as string) / 100;
+ }
+ }
+
+ return { color, position };
+}
+
+function parseLinearGradient(layer: string, width: number, height: number) {
+ const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
+ const parts = splitBackgroundLayers(inside);
+ const dir = (parts.shift() || "").trim();
+
+ let x0 = 0,
+ y0 = 0,
+ x1 = width,
+ y1 = 0; // default: to right
+
+ if (dir.endsWith("deg")) {
+ const deg = parseFloat(dir);
+ const rad = (deg * Math.PI) / 180;
+ const cx = width / 2;
+ const cy = height / 2;
+ const r = Math.hypot(width, height) / 2;
+ x0 = cx - Math.cos(rad) * r;
+ y0 = cy - Math.sin(rad) * r;
+ x1 = cx + Math.cos(rad) * r;
+ y1 = cy + Math.sin(rad) * r;
+ } else if (dir.startsWith("to ")) {
+ const d = dir.slice(3).trim();
+ if (d === "right") {
+ x0 = 0;
+ y0 = 0;
+ x1 = width;
+ y1 = 0;
+ } else if (d === "left") {
+ x0 = width;
+ y0 = 0;
+ x1 = 0;
+ y1 = 0;
+ } else if (d === "bottom") {
+ x0 = 0;
+ y0 = 0;
+ x1 = 0;
+ y1 = height;
+ } else if (d === "top") {
+ x0 = 0;
+ y0 = height;
+ x1 = 0;
+ y1 = 0;
+ }
+ } else {
+ // No direction specified, treat as first color
+ parts.unshift(dir);
+ }
+
+ return { x0, y0, x1, y1, colors: parts };
+}
+
+function parseRadialGradient(layer: string, width: number, height: number) {
+ const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
+ const parts = splitBackgroundLayers(inside);
+ const first = (parts.shift() || "").trim();
+
+ let cx = width / 2;
+ let cy = height / 2;
+
+ if (first.startsWith("circle at")) {
+ const pos = first.replace("circle at", "").trim();
+ const coords = pos.split(/\s+/);
+
+ for (let i = 0; i < coords.length; i += 1) {
+ const coord = coords[i] as string;
+ if (coord.endsWith("%")) {
+ const val = parseFloat(coord) / 100;
+ if (i === 0) cx = val * width;
+ else if (i === 1) cy = val * height;
+ } else if (coord === "left") cx = 0;
+ else if (coord === "right") cx = width;
+ else if (coord === "top") cy = 0;
+ else if (coord === "bottom") cy = height;
+ else if (coord === "center") {
+ if (i === 0) cx = width / 2;
+ else if (i === 1) cy = height / 2;
+ }
+ }
+ } else {
+ parts.unshift(first);
+ }
+
+ // Use farthest-corner for radius
+ const r = Math.max(
+ Math.hypot(cx, cy),
+ Math.hypot(width - cx, cy),
+ Math.hypot(cx, height - cy),
+ Math.hypot(width - cx, height - cy)
+ );
+
+ return { cx, cy, r, colors: parts };
+}
+
+export function drawCssBackground(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ css: string
+): void {
+ const layers = splitBackgroundLayers(css).filter(Boolean);
+
+ // Draw layers from last to first (CSS background order)
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
+ const layer = layers[i] as string;
+
+ if (layer.startsWith("linear-gradient(")) {
+ const { x0, y0, x1, y1, colors } = parseLinearGradient(
+ layer,
+ width,
+ height
+ );
+ const grad = ctx.createLinearGradient(x0, y0, x1, y1);
+ const colorStops = colors.map((c) => parseColorStop(c as string));
+
+ // Handle positions properly
+ for (let j = 0; j < colorStops.length; j += 1) {
+ const stop = colorStops[j] as { color: string; position?: number };
+ const pos = stop.position ?? j / Math.max(1, colorStops.length - 1);
+ grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color);
+ }
+
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, width, height);
+ } else if (layer.startsWith("radial-gradient(")) {
+ const { cx, cy, r, colors } = parseRadialGradient(layer, width, height);
+ const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
+ const colorStops = colors.map((c) => parseColorStop(c as string));
+
+ // Handle positions properly
+ for (let j = 0; j < colorStops.length; j += 1) {
+ const stop = colorStops[j] as { color: string; position?: number };
+ const pos = stop.position ?? j / Math.max(1, colorStops.length - 1);
+ grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color);
+ }
+
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, width, height);
+ } else if (
+ layer.startsWith("#") ||
+ layer.startsWith("rgb") ||
+ layer.startsWith("hsl") ||
+ layer === "transparent" ||
+ layer === "white" ||
+ layer === "black"
+ ) {
+ if (layer !== "transparent") {
+ ctx.fillStyle = layer;
+ ctx.fillRect(0, 0, width, height);
+ }
+ }
+ }
+}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index d2593879..52c58736 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -14,7 +14,7 @@ import { renderTimelineFrame } from "./timeline-renderer";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
-import { DEFAULT_FPS } from "@/stores/project-store";
+import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/stores/project-store";
import { ExportOptions, ExportResult } from "@/types/export";
import { TimelineTrack } from "@/types/timeline";
import { MediaFile } from "@/types/media";
@@ -61,11 +61,12 @@ async function createTimelineAudioBuffer(
for (const element of track.elements) {
if (element.type !== "media") continue;
- const mediaItem = element.type === "media" ? mediaMap.get(element.mediaId) : null;
+ const mediaElement = element;
+ const mediaItem = mediaMap.get(mediaElement.mediaId);
if (!mediaItem || mediaItem.type !== "audio") continue;
const visibleDuration =
- element.duration - element.trimStart - element.trimEnd;
+ mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
if (visibleDuration <= 0) continue;
try {
@@ -77,11 +78,11 @@ async function createTimelineAudioBuffer(
audioElements.push({
buffer: audioBuffer,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- muted: element.muted || track.muted || false,
+ startTime: mediaElement.startTime,
+ duration: mediaElement.duration,
+ trimStart: mediaElement.trimStart,
+ trimEnd: mediaElement.trimEnd,
+ muted: mediaElement.muted || track.muted || false,
});
} catch (error) {
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
@@ -170,7 +171,7 @@ export async function exportProject(
}
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
- const canvasSize = activeProject.canvasSize;
+ const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
const outputFormat =
format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
@@ -251,9 +252,11 @@ export async function exportProject(
canvasHeight: canvas.height,
tracks,
mediaFiles,
+ backgroundType: activeProject.backgroundType,
+ blurIntensity: activeProject.blurIntensity,
backgroundColor:
activeProject.backgroundType === "blur"
- ? "transparent"
+ ? undefined
: activeProject.backgroundColor || "#000000",
projectCanvasSize: canvasSize,
});
diff --git a/apps/web/src/lib/fetch-github-stars.ts b/apps/web/src/lib/fetch-github-stars.ts
deleted file mode 100644
index 0372f25b..00000000
--- a/apps/web/src/lib/fetch-github-stars.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export async function getStars(): Promise<string> {
- try {
- const res = await fetch(
- "https://api.github.com/repos/OpenCut-app/OpenCut",
- {
- next: { revalidate: 3600 },
- }
- );
-
- if (!res.ok) {
- throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
- }
- const data = (await res.json()) as { stargazers_count: number };
- const count = data.stargazers_count;
-
- if (typeof count !== "number") {
- throw new Error("Invalid stargazers_count from GitHub API");
- }
-
- if (count >= 1_000_000)
- return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
- if (count >= 1000)
- return (count / 1000).toFixed(1).replace(/\.0$/, "") + "k";
- return count.toString();
- } catch (error) {
- console.error("Failed to fetch GitHub stars:", error);
- return "1.5k";
- }
-}
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 3aadf883..2688f6e8 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -6,6 +6,7 @@ import {
MediaFileData,
StorageConfig,
SerializedProject,
+ SerializedScene,
TimelineData,
} from "./types";
import { TimelineTrack } from "@/types/timeline";
@@ -39,7 +40,7 @@ class StorageService {
}
// Helper to get project-specific media adapters
- private getProjectMediaAdapters(projectId: string) {
+ private getProjectMediaAdapters({ projectId }: { projectId: string }) {
const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
`${this.config.mediaDb}-${projectId}`,
"media-metadata",
@@ -52,23 +53,43 @@ class StorageService {
}
// Helper to get project-specific timeline adapter
- private getProjectTimelineAdapter(projectId: string) {
+ private getProjectTimelineAdapter({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }) {
+ const dbName = sceneId
+ ? `${this.config.timelineDb}-${projectId}-${sceneId}`
+ : `${this.config.timelineDb}-${projectId}`;
+
return new IndexedDBAdapter<TimelineData>(
- `${this.config.timelineDb}-${projectId}`,
+ dbName,
"timeline",
this.config.version
);
}
// Project operations
- async saveProject(project: TProject): Promise<void> {
+ async saveProject({ project }: { project: TProject }): Promise<void> {
// Convert TProject to serializable format
+ const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ createdAt: scene.createdAt.toISOString(),
+ updatedAt: scene.updatedAt.toISOString(),
+ }));
+
const serializedProject: SerializedProject = {
id: project.id,
name: project.name,
thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
+ scenes: serializedScenes,
+ currentSceneId: project.currentSceneId,
backgroundColor: project.backgroundColor,
backgroundType: project.backgroundType,
blurIntensity: project.blurIntensity,
@@ -81,18 +102,30 @@ class StorageService {
await this.projectsAdapter.set(project.id, serializedProject);
}
- async loadProject(id: string): Promise<TProject | null> {
+ async loadProject({ id }: { id: string }): Promise<TProject | null> {
const serializedProject = await this.projectsAdapter.get(id);
if (!serializedProject) return null;
+ // Now convert serialized scenes back to Scene objects
+ const scenes =
+ serializedProject.scenes?.map((scene) => ({
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ createdAt: new Date(scene.createdAt),
+ updatedAt: new Date(scene.updatedAt),
+ })) || [];
+
// Convert back to TProject format
- return {
+ const project = {
id: serializedProject.id,
name: serializedProject.name,
thumbnail: serializedProject.thumbnail,
createdAt: new Date(serializedProject.createdAt),
updatedAt: new Date(serializedProject.updatedAt),
+ scenes,
+ currentSceneId: serializedProject.currentSceneId || "",
backgroundColor: serializedProject.backgroundColor,
backgroundType: serializedProject.backgroundType,
blurIntensity: serializedProject.blurIntensity,
@@ -101,6 +134,7 @@ class StorageService {
canvasSize: serializedProject.canvasSize,
canvasMode: serializedProject.canvasMode,
};
+ return project;
}
async loadAllProjects(): Promise<TProject[]> {
@@ -108,7 +142,7 @@ class StorageService {
const projects: TProject[] = [];
for (const id of projectIds) {
- const project = await this.loadProject(id);
+ const project = await this.loadProject({ id });
if (project) {
projects.push(project);
}
@@ -120,14 +154,20 @@ class StorageService {
);
}
- async deleteProject(id: string): Promise<void> {
+ async deleteProject({ id }: { id: string }): Promise<void> {
await this.projectsAdapter.remove(id);
}
// Media operations
- async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> {
+ async saveMediaFile({
+ projectId,
+ mediaItem,
+ }: {
+ projectId: string;
+ mediaItem: MediaFile;
+ }): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters(projectId);
+ this.getProjectMediaAdapters({ projectId });
// Save file to project-specific OPFS
await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
@@ -148,12 +188,15 @@ class StorageService {
await mediaMetadataAdapter.set(mediaItem.id, metadata);
}
- async loadMediaFile(
- projectId: string,
- id: string
- ): Promise<MediaFile | null> {
+ async loadMediaFile({
+ projectId,
+ id,
+ }: {
+ projectId: string;
+ id: string;
+ }): Promise<MediaFile | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters(projectId);
+ this.getProjectMediaAdapters({ projectId });
const [file, metadata] = await Promise.all([
mediaFilesAdapter.get(id),
@@ -192,14 +235,20 @@ class StorageService {
};
}
- async loadAllMediaFiles(projectId: string): Promise<MediaFile[]> {
- const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
+ async loadAllMediaFiles({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<MediaFile[]> {
+ const { mediaMetadataAdapter } = this.getProjectMediaAdapters({
+ projectId,
+ });
const mediaIds = await mediaMetadataAdapter.list();
const mediaItems: MediaFile[] = [];
for (const id of mediaIds) {
- const item = await this.loadMediaFile(projectId, id);
+ const item = await this.loadMediaFile({ projectId, id });
if (item) {
mediaItems.push(item);
}
@@ -208,9 +257,15 @@ class StorageService {
return mediaItems;
}
- async deleteMediaFile(projectId: string, id: string): Promise<void> {
+ async deleteMediaFile({
+ projectId,
+ id,
+ }: {
+ projectId: string;
+ id: string;
+ }): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters(projectId);
+ this.getProjectMediaAdapters({ projectId });
await Promise.all([
mediaFilesAdapter.remove(id),
@@ -218,9 +273,13 @@ class StorageService {
]);
}
- async deleteProjectMedia(projectId: string): Promise<void> {
+ async deleteProjectMedia({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters(projectId);
+ this.getProjectMediaAdapters({ projectId });
await Promise.all([
mediaMetadataAdapter.clear(),
@@ -228,12 +287,20 @@ class StorageService {
]);
}
- // Timeline operations - now project-specific
- async saveTimeline(
- projectId: string,
- tracks: TimelineTrack[]
- ): Promise<void> {
- const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ // Timeline operations - supports both legacy and scene-based storage
+ async saveTimeline({
+ projectId,
+ tracks,
+ sceneId,
+ }: {
+ projectId: string;
+ tracks: TimelineTrack[];
+ sceneId?: string;
+ }): Promise<void> {
+ const timelineAdapter = this.getProjectTimelineAdapter({
+ projectId,
+ sceneId,
+ });
const timelineData: TimelineData = {
tracks,
lastModified: new Date().toISOString(),
@@ -241,14 +308,27 @@ class StorageService {
await timelineAdapter.set("timeline", timelineData);
}
- async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> {
- const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ async loadTimeline({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }): Promise<TimelineTrack[] | null> {
+ const timelineAdapter = this.getProjectTimelineAdapter({
+ projectId,
+ sceneId,
+ });
const timelineData = await timelineAdapter.get("timeline");
return timelineData ? timelineData.tracks : null;
}
- async deleteProjectTimeline(projectId: string): Promise<void> {
- const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ async deleteProjectTimeline({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
+ const timelineAdapter = this.getProjectTimelineAdapter({ projectId });
await timelineAdapter.remove("timeline");
}
@@ -274,12 +354,14 @@ class StorageService {
};
}
- async getProjectStorageInfo(projectId: string): Promise<{
+ async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{
mediaItems: number;
hasTimeline: boolean;
}> {
- const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
- const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ const { mediaMetadataAdapter } = this.getProjectMediaAdapters({
+ projectId,
+ });
+ const timelineAdapter = this.getProjectTimelineAdapter({ projectId });
const [mediaIds, timelineData] = await Promise.all([
mediaMetadataAdapter.list(),
@@ -307,7 +389,11 @@ class StorageService {
}
}
- async saveSoundEffect(soundEffect: SoundEffect): Promise<void> {
+ async saveSoundEffect({
+ soundEffect,
+ }: {
+ soundEffect: SoundEffect;
+ }): Promise<void> {
try {
const currentData = await this.loadSavedSounds();
@@ -340,7 +426,7 @@ class StorageService {
}
}
- async removeSavedSound(soundId: number): Promise<void> {
+ async removeSavedSound({ soundId }: { soundId: number }): Promise<void> {
try {
const currentData = await this.loadSavedSounds();
@@ -356,7 +442,7 @@ class StorageService {
}
}
- async isSoundSaved(soundId: number): Promise<boolean> {
+ async isSoundSaved({ soundId }: { soundId: number }): Promise<boolean> {
try {
const currentData = await this.loadSavedSounds();
return currentData.sounds.some((sound) => sound.id === soundId);
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
index 8f593867..e66239cd 100644
--- a/apps/web/src/lib/storage/types.ts
+++ b/apps/web/src/lib/storage/types.ts
@@ -1,4 +1,4 @@
-import { TProject } from "@/types/project";
+import { TProject, Scene } from "@/types/project";
import { TimelineTrack } from "@/types/timeline";
export interface StorageAdapter<T> {
@@ -23,11 +23,23 @@ export interface MediaFileData {
// File will be stored separately in OPFS
}
+// Legacy timeline data, kept for backward compatibility
export interface TimelineData {
tracks: TimelineTrack[];
lastModified: string;
}
+export interface SceneTimelineData {
+ sceneId: string;
+ tracks: TimelineTrack[];
+ lastModified: string;
+}
+
+export type SerializedScene = Omit<Scene, "createdAt" | "updatedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+};
+
export interface StorageConfig {
projectsDb: string;
mediaDb: string;
@@ -37,9 +49,13 @@ export interface StorageConfig {
}
// Helper type for serialization - converts Date objects to strings
-export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
+export type SerializedProject = Omit<
+ TProject,
+ "createdAt" | "updatedAt" | "scenes"
+> & {
createdAt: string;
updatedAt: string;
+ scenes: SerializedScene[];
bookmarks?: number[];
};
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts
index 6241a7d9..b2650b7c 100644
--- a/apps/web/src/lib/timeline-renderer.ts
+++ b/apps/web/src/lib/timeline-renderer.ts
@@ -1,6 +1,8 @@
-import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
import type { TimelineTrack } from "@/types/timeline";
import type { MediaFile } from "@/types/media";
+import type { BlurIntensity } from "@/types/project";
+import { videoCache } from "./video-cache";
+import { drawCssBackground } from "./canvas-gradients";
export interface RenderContext {
ctx: CanvasRenderingContext2D;
@@ -10,9 +12,29 @@ export interface RenderContext {
tracks: TimelineTrack[];
mediaFiles: MediaFile[];
backgroundColor?: string;
+ backgroundType?: "color" | "blur";
+ blurIntensity?: BlurIntensity;
projectCanvasSize?: { width: number; height: number };
}
+const imageElementCache = new Map<string, HTMLImageElement>();
+
+async function getImageElement(
+ mediaItem: MediaFile
+): Promise<HTMLImageElement> {
+ const cacheKey = mediaItem.id;
+ const cached = imageElementCache.get(cacheKey);
+ if (cached) return cached;
+ const img = new Image();
+ await new Promise<void>((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject(new Error("Image load failed"));
+ img.src = mediaItem.url || URL.createObjectURL(mediaItem.file);
+ });
+ imageElementCache.set(cacheKey, img);
+ return img;
+}
+
export async function renderTimelineFrame({
ctx,
time,
@@ -21,15 +43,26 @@ export async function renderTimelineFrame({
tracks,
mediaFiles,
backgroundColor,
+ backgroundType,
+ blurIntensity,
projectCanvasSize,
}: RenderContext): Promise<void> {
// Background
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
- if (backgroundColor && backgroundColor !== "transparent") {
+ if (
+ backgroundColor &&
+ backgroundColor !== "transparent" &&
+ !backgroundColor.includes("gradient")
+ ) {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
}
+ // If backgroundColor is a CSS gradient string, draw it
+ if (backgroundColor && backgroundColor.includes("gradient")) {
+ drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
+ }
+
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
const scaleY = projectCanvasSize
? canvasHeight / projectCanvasSize.height
@@ -44,7 +77,7 @@ export async function renderTimelineFrame({
for (let t = tracks.length - 1; t >= 0; t -= 1) {
const track = tracks[t];
for (const element of track.elements) {
- if ((element as any).hidden) continue;
+ if (element.hidden) continue;
const elementStart = element.startTime;
const elementEnd =
element.startTime +
@@ -62,34 +95,103 @@ export async function renderTimelineFrame({
}
}
+ // If background is set to blur, draw the active media as a blurred cover layer first
+ if (backgroundType === "blur") {
+ const blurPx = Math.max(0, blurIntensity ?? 8);
+ // Find a suitable media element (video/image) among active elements
+ const bgCandidate = active.find(({ element, mediaItem }) => {
+ return (
+ element.type === "media" &&
+ mediaItem !== null &&
+ (mediaItem.type === "video" || mediaItem.type === "image")
+ );
+ });
+ if (bgCandidate && bgCandidate.mediaItem) {
+ const { element, mediaItem } = bgCandidate;
+ try {
+ if (mediaItem.type === "video") {
+ const localTime = time - element.startTime + element.trimStart;
+ const frame = await videoCache.getFrameAt(
+ mediaItem.id,
+ mediaItem.file,
+ Math.max(0, localTime)
+ );
+ if (frame) {
+ const mediaW = Math.max(1, mediaItem.width || canvasWidth);
+ const mediaH = Math.max(1, mediaItem.height || canvasHeight);
+ const coverScale = Math.max(
+ canvasWidth / mediaW,
+ canvasHeight / mediaH
+ );
+ const drawW = mediaW * coverScale;
+ const drawH = mediaH * coverScale;
+ const drawX = (canvasWidth - drawW) / 2;
+ const drawY = (canvasHeight - drawH) / 2;
+ ctx.save();
+ ctx.filter = `blur(${blurPx}px)`;
+ ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
+ ctx.restore();
+ }
+ } else if (mediaItem.type === "image") {
+ const img = await getImageElement(mediaItem);
+ const mediaW = Math.max(
+ 1,
+ mediaItem.width || img.naturalWidth || canvasWidth
+ );
+ const mediaH = Math.max(
+ 1,
+ mediaItem.height || img.naturalHeight || canvasHeight
+ );
+ const coverScale = Math.max(
+ canvasWidth / mediaW,
+ canvasHeight / mediaH
+ );
+ const drawW = mediaW * coverScale;
+ const drawH = mediaH * coverScale;
+ const drawX = (canvasWidth - drawW) / 2;
+ const drawY = (canvasHeight - drawH) / 2;
+ ctx.save();
+ ctx.filter = `blur(${blurPx}px)`;
+ ctx.drawImage(img, drawX, drawY, drawW, drawH);
+ ctx.restore();
+ }
+ } catch {
+ // Ignore background blur failures; foreground will still render
+ }
+ }
+ }
+
for (const { element, mediaItem } of active) {
if (element.type === "media" && mediaItem) {
if (mediaItem.type === "video") {
- const input = new Input({
- source: new BlobSource(mediaItem.file),
- formats: ALL_FORMATS,
- });
- const track = await input.getPrimaryVideoTrack();
- if (!track) continue;
- const decodable = await track.canDecode();
- if (!decodable) continue;
- const sink = new VideoSampleSink(track);
+ try {
+ const localTime = time - element.startTime + element.trimStart;
- const localTime = time - element.startTime + element.trimStart;
- const sample = await sink.getSample(localTime);
- if (!sample) continue;
+ const frame = await videoCache.getFrameAt(
+ mediaItem.id,
+ mediaItem.file,
+ localTime
+ );
+ if (!frame) continue;
- const mediaW = Math.max(1, mediaItem.width || canvasWidth);
- const mediaH = Math.max(1, mediaItem.height || canvasHeight);
- const containScale = Math.min(
- canvasWidth / mediaW,
- canvasHeight / mediaH
- );
- const drawW = mediaW * containScale;
- const drawH = mediaH * containScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
- sample.draw(ctx, drawX, drawY, drawW, drawH);
+ const mediaW = Math.max(1, mediaItem.width || canvasWidth);
+ const mediaH = Math.max(1, mediaItem.height || canvasHeight);
+ const containScale = Math.min(
+ canvasWidth / mediaW,
+ canvasHeight / mediaH
+ );
+ const drawW = mediaW * containScale;
+ const drawH = mediaH * containScale;
+ const drawX = (canvasWidth - drawW) / 2;
+ const drawY = (canvasHeight - drawH) / 2;
+
+ ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
+ } catch (error) {
+ console.warn(
+ `Failed to render video frame for ${mediaItem.name}:`,
+ error
+ );
+ }
}
if (mediaItem.type === "image") {
const img = new Image();
@@ -118,33 +220,47 @@ export async function renderTimelineFrame({
}
}
if (element.type === "text") {
- const posX = canvasWidth / 2 + (element as any).x * scaleX;
- const posY = canvasHeight / 2 + (element as any).y * scaleY;
+ const text = element;
+ const posX = canvasWidth / 2 + text.x * scaleX;
+ const posY = canvasHeight / 2 + text.y * scaleY;
ctx.save();
ctx.translate(posX, posY);
- ctx.rotate(((element as any).rotation * Math.PI) / 180);
- ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity));
- const px = (element as any).fontSize * scaleX;
- const weight = (element as any).fontWeight === "bold" ? "bold " : "";
- const style = (element as any).fontStyle === "italic" ? "italic " : "";
- ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`;
- ctx.fillStyle = (element as any).color;
- ctx.textAlign = (element as any).textAlign;
+ ctx.rotate((text.rotation * Math.PI) / 180);
+ ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity));
+ const px = text.fontSize * scaleX;
+ const weight = text.fontWeight === "bold" ? "bold " : "";
+ const style = text.fontStyle === "italic" ? "italic " : "";
+ ctx.font = `${style}${weight}${px}px ${text.fontFamily}`;
+ ctx.fillStyle = text.color;
+ ctx.textAlign = text.textAlign as CanvasTextAlign;
ctx.textBaseline = "middle";
- const metrics = ctx.measureText((element as any).content);
- const ascent =
- (metrics as unknown as { actualBoundingBoxAscent?: number })
- .actualBoundingBoxAscent ?? px * 0.8;
- const descent =
- (metrics as unknown as { actualBoundingBoxDescent?: number })
- .actualBoundingBoxDescent ?? px * 0.2;
+ const metrics = ctx.measureText(text.content);
+ const hasBoxMetrics =
+ "actualBoundingBoxAscent" in metrics &&
+ "actualBoundingBoxDescent" in metrics;
+ const ascent = hasBoxMetrics
+ ? (
+ metrics as TextMetrics & {
+ actualBoundingBoxAscent: number;
+ actualBoundingBoxDescent: number;
+ }
+ ).actualBoundingBoxAscent
+ : px * 0.8;
+ const descent = hasBoxMetrics
+ ? (
+ metrics as TextMetrics & {
+ actualBoundingBoxAscent: number;
+ actualBoundingBoxDescent: number;
+ }
+ ).actualBoundingBoxDescent
+ : px * 0.2;
const textW = metrics.width;
const textH = ascent + descent;
const padX = 8 * scaleX;
const padY = 4 * scaleX;
- if ((element as any).backgroundColor) {
+ if (text.backgroundColor) {
ctx.save();
- ctx.fillStyle = (element as any).backgroundColor;
+ ctx.fillStyle = text.backgroundColor;
let bgLeft = -textW / 2;
if (ctx.textAlign === "left") bgLeft = 0;
if (ctx.textAlign === "right") bgLeft = -textW;
@@ -156,7 +272,7 @@ export async function renderTimelineFrame({
);
ctx.restore();
}
- ctx.fillText((element as any).content, 0, 0);
+ ctx.fillText(text.content, 0, 0);
ctx.restore();
}
}
diff --git a/apps/web/src/lib/video-cache.ts b/apps/web/src/lib/video-cache.ts
new file mode 100644
index 00000000..aeb62d1c
--- /dev/null
+++ b/apps/web/src/lib/video-cache.ts
@@ -0,0 +1,186 @@
+import {
+ Input,
+ ALL_FORMATS,
+ BlobSource,
+ CanvasSink,
+ WrappedCanvas,
+} from "mediabunny";
+
+interface VideoSinkData {
+ sink: CanvasSink;
+ iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
+ currentFrame: WrappedCanvas | null;
+ lastTime: number;
+}
+export class VideoCache {
+ private sinks = new Map<string, VideoSinkData>();
+ private initPromises = new Map<string, Promise<void>>();
+
+ async getFrameAt(
+ mediaId: string,
+ file: File,
+ time: number
+ ): Promise<WrappedCanvas | null> {
+ await this.ensureSink(mediaId, file);
+
+ const sinkData = this.sinks.get(mediaId);
+ if (!sinkData) return null;
+
+ if (
+ sinkData.currentFrame &&
+ this.isFrameValid(sinkData.currentFrame, time)
+ ) {
+ return sinkData.currentFrame;
+ }
+
+ if (
+ sinkData.iterator &&
+ sinkData.currentFrame &&
+ time >= sinkData.lastTime &&
+ time < sinkData.lastTime + 2.0
+ ) {
+ const frame = await this.iterateToTime(sinkData, time);
+ if (frame) return frame;
+ }
+
+ return await this.seekToTime(sinkData, time);
+ }
+
+ private isFrameValid(frame: WrappedCanvas, time: number): boolean {
+ return time >= frame.timestamp && time < frame.timestamp + frame.duration;
+ }
+ private async iterateToTime(
+ sinkData: VideoSinkData,
+ targetTime: number
+ ): Promise<WrappedCanvas | null> {
+ if (!sinkData.iterator) return null;
+
+ try {
+ while (true) {
+ const { value: frame, done } = await sinkData.iterator.next();
+
+ if (done || !frame) break;
+
+ sinkData.currentFrame = frame;
+ sinkData.lastTime = frame.timestamp;
+
+ if (this.isFrameValid(frame, targetTime)) {
+ return frame;
+ }
+
+ if (frame.timestamp > targetTime + 1.0) break;
+ }
+ } catch (error) {
+ console.warn("Iterator failed, will restart:", error);
+ sinkData.iterator = null;
+ }
+
+ return null;
+ }
+ private async seekToTime(
+ sinkData: VideoSinkData,
+ time: number
+ ): Promise<WrappedCanvas | null> {
+ try {
+ if (sinkData.iterator) {
+ await sinkData.iterator.return();
+ sinkData.iterator = null;
+ }
+
+ sinkData.iterator = sinkData.sink.canvases(time);
+ sinkData.lastTime = time;
+
+ const { value: frame } = await sinkData.iterator.next();
+
+ if (frame) {
+ sinkData.currentFrame = frame;
+ return frame;
+ }
+ } catch (error) {
+ console.warn("Failed to seek video:", error);
+ }
+
+ return null;
+ }
+ private async ensureSink(mediaId: string, file: File): Promise<void> {
+ if (this.sinks.has(mediaId)) return;
+
+ if (this.initPromises.has(mediaId)) {
+ await this.initPromises.get(mediaId);
+ return;
+ }
+
+ const initPromise = this.initializeSink(mediaId, file);
+ this.initPromises.set(mediaId, initPromise);
+
+ try {
+ await initPromise;
+ } finally {
+ this.initPromises.delete(mediaId);
+ }
+ }
+ private async initializeSink(mediaId: string, file: File): Promise<void> {
+ try {
+ const input = new Input({
+ source: new BlobSource(file),
+ formats: ALL_FORMATS,
+ });
+
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack) {
+ throw new Error("No video track found");
+ }
+
+ const canDecode = await videoTrack.canDecode();
+ if (!canDecode) {
+ throw new Error("Video codec not supported for decoding");
+ }
+
+ const sink = new CanvasSink(videoTrack, {
+ poolSize: 3,
+ fit: "contain",
+ });
+
+ this.sinks.set(mediaId, {
+ sink,
+ iterator: null,
+ currentFrame: null,
+ lastTime: -1,
+ });
+ } catch (error) {
+ console.error(`Failed to initialize video sink for ${mediaId}:`, error);
+ throw error;
+ }
+ }
+
+ clearVideo(mediaId: string): void {
+ const sinkData = this.sinks.get(mediaId);
+ if (sinkData) {
+ if (sinkData.iterator) {
+ sinkData.iterator.return();
+ }
+
+ this.sinks.delete(mediaId);
+ }
+
+ this.initPromises.delete(mediaId);
+ }
+
+ clearAll(): void {
+ for (const [mediaId] of this.sinks) {
+ this.clearVideo(mediaId);
+ }
+ }
+
+ getStats() {
+ return {
+ totalSinks: this.sinks.size,
+ activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator)
+ .length,
+ cachedFrames: Array.from(this.sinks.values()).filter(
+ (s) => s.currentFrame
+ ).length,
+ };
+ }
+}
+export const videoCache = new VideoCache();
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
index 7fa0da5e..febcaa30 100644
--- a/apps/web/src/stores/keybindings-store.ts
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -206,6 +206,9 @@ function getPressedKey(ev: KeyboardEvent): string | null {
const key = (ev.key ?? "").toLowerCase();
const code = ev.code ?? "";
+ if (code === "Space" || key === " " || key === "spacebar" || key === "space")
+ return "space";
+
// Check arrow keys
if (key.startsWith("arrow")) {
return key.slice(5);
@@ -213,7 +216,6 @@ function getPressedKey(ev: KeyboardEvent): string | null {
// Check for special keys
if (key === "tab") return "tab";
- if (key === " " || key === "space") return "space";
if (key === "home") return "home";
if (key === "end") return "end";
if (key === "delete") return "delete";
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 2b7cb62e..61c771db 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -3,6 +3,7 @@ import { storageService } from "@/lib/storage/storage-service";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
import { MediaType, MediaFile } from "@/types/media";
+import { videoCache } from "@/lib/video-cache";
interface MediaStore {
mediaFiles: MediaFile[];
@@ -151,7 +152,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// Save to persistent storage in background
try {
- await storageService.saveMediaFile(projectId, newItem);
+ await storageService.saveMediaFile({ projectId, mediaItem: newItem });
} catch (error) {
console.error("Failed to save media item:", error);
// Remove from local state if save failed
@@ -165,6 +166,8 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
const state = get();
const item = state.mediaFiles.find((media) => media.id === id);
+ videoCache.clearVideo(id);
+
// Cleanup object URLs to prevent memory leaks
if (item?.url) {
URL.revokeObjectURL(item.url);
@@ -200,6 +203,12 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// If there are elements to remove, push history once before batch removal
if (elementsToRemove.length > 0) {
+ const {
+ removeElementFromTrack,
+ removeElementFromTrackWithRipple,
+ rippleEditingEnabled,
+ pushHistory,
+ } = useTimelineStore.getState();
pushHistory();
// Remove all elements without pushing additional history entries
@@ -214,7 +223,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// 3) Remove from persistent storage
try {
- await storageService.deleteMediaFile(projectId, id);
+ await storageService.deleteMediaFile({ projectId, id });
} catch (error) {
console.error("Failed to delete media item:", error);
}
@@ -224,7 +233,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
set({ isLoading: true });
try {
- const mediaItems = await storageService.loadAllMediaFiles(projectId);
+ const mediaItems = await storageService.loadAllMediaFiles({ projectId });
// Regenerate thumbnails for video items
const updatedMediaItems = await Promise.all(
@@ -279,7 +288,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
try {
const mediaIds = state.mediaFiles.map((item) => item.id);
await Promise.all(
- mediaIds.map((id) => storageService.deleteMediaFile(projectId, id))
+ mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id }))
);
} catch (error) {
console.error("Failed to clear media items from storage:", error);
@@ -289,6 +298,8 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
clearAllMedia: () => {
const state = get();
+ videoCache.clearAll();
+
// Cleanup all object URLs
state.mediaFiles.forEach((item) => {
if (item.url) {
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index b924ea22..c58f7309 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -1,28 +1,45 @@
-import { TProject } from "@/types/project";
+import { TProject, BlurIntensity, Scene } from "@/types/project";
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
+import { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils";
import { CanvasSize, CanvasMode } from "@/types/editor";
export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 };
export const DEFAULT_FPS = 30;
-const DEFAULT_PROJECT: TProject = {
- id: generateUUID(),
- name: "Untitled",
- thumbnail: "",
- createdAt: new Date(),
- updatedAt: new Date(),
- backgroundColor: "#000000",
- backgroundType: "color",
- blurIntensity: 8,
- bookmarks: [],
- fps: DEFAULT_FPS,
- canvasSize: DEFAULT_CANVAS_SIZE,
- canvasMode: "preset",
+export function createMainScene(): Scene {
+ return {
+ id: generateUUID(),
+ name: "Main Scene",
+ isMain: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+const createDefaultProject = (name: string): TProject => {
+ const mainScene = createMainScene();
+
+ return {
+ id: generateUUID(),
+ name,
+ thumbnail: "",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ scenes: [mainScene],
+ currentSceneId: mainScene.id,
+ backgroundColor: "#000000",
+ backgroundType: "color",
+ blurIntensity: 8,
+ bookmarks: [],
+ fps: DEFAULT_FPS,
+ canvasSize: DEFAULT_CANVAS_SIZE,
+ canvasMode: "preset",
+ };
};
interface ProjectStore {
@@ -44,7 +61,7 @@ interface ProjectStore {
updateProjectBackground: (backgroundColor: string) => Promise<void>;
updateBackgroundType: (
type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number }
+ options?: { backgroundColor?: string; blurIntensity?: BlurIntensity }
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>;
@@ -104,7 +121,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
};
try {
- await storageService.saveProject(updatedProject);
+ await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
@@ -152,7 +169,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
};
try {
- await storageService.saveProject(updatedProject);
+ await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
@@ -164,23 +181,24 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
},
createNewProject: async (name: string) => {
- const newProject: TProject = {
- ...DEFAULT_PROJECT,
- id: generateUUID(),
- name,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
+ const newProject = createDefaultProject(name);
set({ activeProject: newProject });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
+ const sceneStore = useSceneStore.getState();
+
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
+ sceneStore.initializeScenes({
+ scenes: newProject.scenes,
+ currentSceneId: newProject.currentSceneId,
+ });
+
try {
- await storageService.saveProject(newProject);
+ await storageService.saveProject({ project: newProject });
// Reload all projects to update the list
await get().loadAllProjects();
return newProject.id;
@@ -195,21 +213,39 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
set({ isLoading: true });
}
- // Clear media and timeline immediately to prevent flickering when switching projects
+ // Prevent flicker when switching projects - clear all stores
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
+ const sceneStore = useSceneStore.getState();
+
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
+ sceneStore.clearScenes();
try {
- const project = await storageService.loadProject(id);
+ const project = await storageService.loadProject({ id });
if (project) {
set({ activeProject: project });
- // Load project-specific data in parallel
+ let currentScene = null;
+ if (project.scenes && project.scenes.length > 0) {
+ sceneStore.initializeScenes({
+ scenes: project.scenes,
+ currentSceneId: project.currentSceneId,
+ });
+ // Get current scene directly from project data (don't rely on store state)
+ currentScene =
+ project.scenes.find((s) => s.id === project.currentSceneId) ||
+ project.scenes.find((s) => s.isMain) ||
+ project.scenes[0];
+ }
+
await Promise.all([
mediaStore.loadProjectMedia(id),
- timelineStore.loadProjectTimeline(id),
+ timelineStore.loadProjectTimeline({
+ projectId: id,
+ sceneId: currentScene?.id,
+ }),
]);
} else {
throw new Error(`Project with id ${id} not found`);
@@ -227,11 +263,16 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
if (!activeProject) return;
try {
- // Save project metadata and timeline data in parallel
const timelineStore = useTimelineStore.getState();
+ const sceneStore = useSceneStore.getState();
+ const currentScene = sceneStore.currentScene;
+
await Promise.all([
- storageService.saveProject(activeProject),
- timelineStore.saveProjectTimeline(activeProject.id),
+ storageService.saveProject({ project: activeProject }),
+ timelineStore.saveProjectTimeline({
+ projectId: activeProject.id,
+ sceneId: currentScene?.id,
+ }),
]);
await get().loadAllProjects(); // Refresh the list
} catch (error) {
@@ -256,22 +297,24 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
deleteProject: async (id: string) => {
try {
- // Delete project data in parallel
await Promise.all([
- storageService.deleteProjectMedia(id),
- storageService.deleteProjectTimeline(id),
- storageService.deleteProject(id),
+ storageService.deleteProjectMedia({ projectId: id }),
+ storageService.deleteProjectTimeline({ projectId: id }),
+ storageService.deleteProject({ id }),
]);
await get().loadAllProjects(); // Refresh the list
- // If we deleted the active project, close it and clear data
+ // If deleted active project, close it and clear data
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
+ const sceneStore = useSceneStore.getState();
+
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
+ sceneStore.clearScenes();
}
} catch (error) {
console.error("Failed to delete project:", error);
@@ -281,11 +324,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
closeProject: () => {
set({ activeProject: null });
- // Clear data from stores when closing project
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
+ const sceneStore = useSceneStore.getState();
+
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
+ sceneStore.clearScenes();
},
renameProject: async (id: string, name: string) => {
@@ -307,12 +352,11 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
};
try {
- // Save to storage
- await storageService.saveProject(updatedProject);
+ await storageService.saveProject({ project: updatedProject });
await get().loadAllProjects();
- // Update activeProject if it's the same project
+ // Update activeProject if same project
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: updatedProject });
@@ -328,7 +372,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
duplicateProject: async (projectId: string) => {
try {
- const project = await storageService.loadProject(projectId);
+ const project = await storageService.loadProject({ id: projectId });
if (!project) {
toast.error("Project not found", {
description: "Please try again",
@@ -355,14 +399,14 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
- ...project, // Copy all properties from the original project
+ ...project,
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
};
- await storageService.saveProject(newProject);
+ await storageService.saveProject({ project: newProject });
await get().loadAllProjects();
return newProject.id;
} catch (error) {
@@ -386,9 +430,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
};
try {
- await storageService.saveProject(updatedProject);
+ await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
+ await get().loadAllProjects();
} catch (error) {
console.error("Failed to update project background:", error);
toast.error("Failed to update background", {
@@ -399,7 +443,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
updateBackgroundType: async (
type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number }
+ options?: { backgroundColor?: string; blurIntensity?: BlurIntensity }
) => {
const { activeProject } = get();
if (!activeProject) return;
@@ -410,14 +454,16 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
...(options?.backgroundColor && {
backgroundColor: options.backgroundColor,
}),
- ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
+ ...(options?.blurIntensity !== undefined && {
+ blurIntensity: options.blurIntensity,
+ }),
updatedAt: new Date(),
};
try {
- await storageService.saveProject(updatedProject);
+ await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
+ await get().loadAllProjects();
} catch (error) {
console.error("Failed to update background type:", error);
toast.error("Failed to update background", {
@@ -437,9 +483,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
};
try {
- await storageService.saveProject(updatedProject);
+ await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
+ await get().loadAllProjects();
} catch (error) {
console.error("Failed to update project FPS:", error);
toast.error("Failed to update project FPS", {
@@ -460,9 +506,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
};
try {
- await storageService.saveProject(updatedProject);
+ await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
+ await get().loadAllProjects();
} catch (error) {
console.error("Failed to update canvas size:", error);
toast.error("Failed to update canvas size", {
@@ -474,12 +520,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
const { savedProjects } = get();
- // Filter projects by search query
const filteredProjects = savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase())
);
- // Sort filtered projects
const sortedProjects = [...filteredProjects].sort((a, b) => {
const [key, order] = sortOption.split("-");
@@ -506,7 +550,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
return sortedProjects;
},
- // Global invalid project ID tracking implementation
+ // Global invalid project ID tracking
isInvalidProjectId: (id: string) => {
const invalidIds = get().invalidProjectIds || new Set();
return invalidIds.has(id);
diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts
new file mode 100644
index 00000000..a9f65a49
--- /dev/null
+++ b/apps/web/src/stores/scene-store.ts
@@ -0,0 +1,318 @@
+import { create } from "zustand";
+import { Scene } from "@/types/project";
+import { useProjectStore } from "./project-store";
+import { useTimelineStore } from "./timeline-store";
+import { storageService } from "@/lib/storage/storage-service";
+import { generateUUID } from "@/lib/utils";
+
+export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null {
+ return scenes.find((scene) => scene.isMain) || null;
+}
+
+function ensureMainScene(scenes: Scene[]): Scene[] {
+ const hasMain = scenes.some((scene) => scene.isMain);
+ if (!hasMain) {
+ const mainScene: Scene = {
+ id: generateUUID(),
+ name: "Main scene",
+ isMain: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ return [mainScene, ...scenes];
+ }
+ return scenes;
+}
+
+interface SceneStore {
+ // Current scene state
+ currentScene: Scene | null;
+ scenes: Scene[];
+
+ // Scene management
+ createScene: ({
+ name,
+ isMain,
+ }: {
+ name: string;
+ isMain: boolean;
+ }) => Promise<string>;
+ deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
+ renameScene: ({
+ sceneId,
+ name,
+ }: {
+ sceneId: string;
+ name: string;
+ }) => Promise<void>;
+ switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
+
+ // Scene utilities
+ getMainScene: () => Scene | null;
+ getCurrentScene: () => Scene | null;
+
+ // Project integration
+ loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>;
+ initializeScenes: ({
+ scenes,
+ currentSceneId,
+ }: {
+ scenes: Scene[];
+ currentSceneId?: string;
+ }) => void;
+ clearScenes: () => void;
+}
+
+export const useSceneStore = create<SceneStore>((set, get) => ({
+ currentScene: null,
+ scenes: [],
+
+ createScene: async ({ name, isMain = false }) => {
+ const { scenes } = get();
+
+ const newScene = {
+ id: generateUUID(),
+ name,
+ isMain,
+ isBackground: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ const updatedScenes = [...scenes, newScene];
+
+ const projectStore = useProjectStore.getState();
+ const { activeProject } = projectStore;
+
+ if (!activeProject) {
+ throw new Error("No active project");
+ }
+
+ const updatedProject = {
+ ...activeProject,
+ scenes: updatedScenes,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject({ project: updatedProject });
+ useProjectStore.setState({ activeProject: updatedProject });
+ set({ scenes: updatedScenes });
+ return newScene.id;
+ } catch (error) {
+ console.error("Failed to create scene:", error);
+ throw error;
+ }
+ },
+
+ deleteScene: async ({ sceneId }: { sceneId: string }) => {
+ const { scenes, currentScene } = get();
+ const sceneToDelete = scenes.find((s) => s.id === sceneId);
+
+ if (!sceneToDelete) {
+ throw new Error("Scene not found");
+ }
+
+ if (sceneToDelete.isMain) {
+ throw new Error("Cannot delete main scene");
+ }
+
+ const updatedScenes = scenes.filter((s) => s.id !== sceneId);
+
+ // Determine new current scene if we're deleting the current one
+ let newCurrentScene = currentScene;
+ if (currentScene?.id === sceneId) {
+ newCurrentScene = getMainScene({ scenes: updatedScenes });
+ }
+
+ // Update project
+ const projectStore = useProjectStore.getState();
+ const { activeProject } = projectStore;
+
+ if (!activeProject) {
+ throw new Error("No active project");
+ }
+
+ const updatedProject = {
+ ...activeProject,
+ scenes: updatedScenes,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject({ project: updatedProject });
+ useProjectStore.setState({ activeProject: updatedProject });
+ set({
+ scenes: updatedScenes,
+ currentScene: newCurrentScene,
+ });
+
+ // If we switched scenes, load the new scene's timeline
+ if (newCurrentScene && newCurrentScene.id !== currentScene?.id) {
+ const timelineStore = useTimelineStore.getState();
+ await timelineStore.loadProjectTimeline({
+ projectId: activeProject.id,
+ sceneId: newCurrentScene.id,
+ });
+ }
+ } catch (error) {
+ console.error("Failed to delete scene:", error);
+ throw error;
+ }
+ },
+
+ renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => {
+ const { scenes } = get();
+ const updatedScenes = scenes.map((scene) =>
+ scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene
+ );
+
+ // Update project
+ const projectStore = useProjectStore.getState();
+ const { activeProject } = projectStore;
+
+ if (!activeProject) {
+ throw new Error("No active project");
+ }
+
+ const updatedProject = {
+ ...activeProject,
+ scenes: updatedScenes,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject({ project: updatedProject });
+ useProjectStore.setState({ activeProject: updatedProject });
+ set({
+ scenes: updatedScenes,
+ currentScene: updatedScenes.find((s) => s.id === sceneId) || null,
+ });
+ } catch (error) {
+ console.error("Failed to rename scene:", error);
+ throw error;
+ }
+ },
+
+ switchToScene: async ({ sceneId }: { sceneId: string }) => {
+ const { scenes } = get();
+ const targetScene = scenes.find((s) => s.id === sceneId);
+
+ if (!targetScene) {
+ throw new Error("Scene not found");
+ }
+
+ const timelineStore = useTimelineStore.getState();
+ const projectStore = useProjectStore.getState();
+ const { activeProject } = projectStore;
+ const { currentScene } = get();
+
+ if (activeProject && currentScene) {
+ await timelineStore.saveProjectTimeline({
+ projectId: activeProject.id,
+ sceneId: currentScene.id,
+ });
+ }
+
+ if (activeProject) {
+ await timelineStore.loadProjectTimeline({
+ projectId: activeProject.id,
+ sceneId,
+ });
+
+ const updatedProject = {
+ ...activeProject,
+ currentSceneId: sceneId,
+ updatedAt: new Date(),
+ };
+
+ await storageService.saveProject({ project: updatedProject });
+ useProjectStore.setState({ activeProject: updatedProject });
+ }
+
+ set({ currentScene: targetScene });
+ },
+
+ getMainScene: () => {
+ const { scenes } = get();
+ return scenes.find((scene) => scene.isMain) || null;
+ },
+
+ getCurrentScene: () => {
+ return get().currentScene;
+ },
+
+ loadProjectScenes: async ({ projectId }: { projectId: string }) => {
+ try {
+ const project = await storageService.loadProject({ id: projectId });
+ if (project?.scenes) {
+ const ensuredScenes = project.scenes.map((scene) => ({
+ ...scene,
+ isMain: scene.isMain || false,
+ }));
+ const currentScene =
+ ensuredScenes.find((s) => s.id === project.currentSceneId) ||
+ ensuredScenes[0];
+
+ set({
+ scenes: ensuredScenes,
+ currentScene,
+ });
+ }
+ } catch (error) {
+ console.error("Failed to load project scenes:", error);
+ set({ scenes: [], currentScene: null });
+ }
+ },
+
+ initializeScenes: ({
+ scenes,
+ currentSceneId,
+ }: {
+ scenes: Scene[];
+ currentSceneId?: string;
+ }) => {
+ const ensuredScenes = ensureMainScene(scenes);
+ const currentScene = currentSceneId
+ ? ensuredScenes.find((s) => s.id === currentSceneId)
+ : null;
+
+ const fallbackScene = getMainScene({ scenes: ensuredScenes });
+
+ set({
+ scenes: ensuredScenes,
+ currentScene: currentScene || fallbackScene,
+ });
+
+ if (ensuredScenes.length > scenes.length) {
+ const projectStore = useProjectStore.getState();
+ const { activeProject } = projectStore;
+
+ if (activeProject) {
+ const updatedProject = {
+ ...activeProject,
+ scenes: ensuredScenes,
+ updatedAt: new Date(),
+ };
+
+ storageService
+ .saveProject({ project: updatedProject })
+ .then(() => {
+ useProjectStore.setState({ activeProject: updatedProject });
+ })
+ .catch((error) => {
+ console.error(
+ "Failed to save project with background scene:",
+ error
+ );
+ });
+ }
+ }
+ },
+
+ clearScenes: () => {
+ set({
+ scenes: [],
+ currentScene: null,
+ });
+ },
+}));
diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts
index 6dffa198..579eee7a 100644
--- a/apps/web/src/stores/sounds-store.ts
+++ b/apps/web/src/stores/sounds-store.ts
@@ -162,7 +162,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
saveSoundEffect: async (soundEffect: SoundEffect) => {
try {
- await storageService.saveSoundEffect(soundEffect);
+ await storageService.saveSoundEffect({ soundEffect });
// Refresh saved sounds
const savedSoundsData = await storageService.loadSavedSounds();
@@ -178,7 +178,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
removeSavedSound: async (soundId: number) => {
try {
- await storageService.removeSavedSound(soundId);
+ await storageService.removeSavedSound({ soundId });
// Update local state immediately
set((state) => ({
diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts
index da8b926b..c7c9ef98 100644
--- a/apps/web/src/stores/stickers-store.ts
+++ b/apps/web/src/stores/stickers-store.ts
@@ -9,6 +9,12 @@ import {
type CollectionInfo,
type IconSearchResult,
} from "@/lib/iconify-api";
+import { useProjectStore } from "@/stores/project-store";
+import { useMediaStore } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { MediaFile } from "@/types/media";
export type StickerCategory = "all" | "general" | "brands" | "emoji";
@@ -26,6 +32,7 @@ interface StickersStore {
isLoadingCollection: boolean;
isSearching: boolean;
isDownloading: boolean;
+ addingSticker: string | null;
setSearchQuery: (query: string) => void;
setSelectedCategory: (category: StickerCategory) => void;
@@ -36,6 +43,7 @@ interface StickersStore {
loadCollection: (prefix: string) => Promise<void>;
searchStickers: (query: string) => Promise<void>;
downloadSticker: (iconName: string) => Promise<File | null>;
+ addStickerToTimeline: (iconName: string) => Promise<void>;
addToRecentStickers: (iconName: string) => void;
clearRecentStickers: () => void;
@@ -58,6 +66,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
isLoadingCollection: false,
isSearching: false,
isDownloading: false,
+ addingSticker: null,
setSearchQuery: (query) => set({ searchQuery: query }),
@@ -162,6 +171,50 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
}
},
+ addStickerToTimeline: async (iconName: string) => {
+ set({ addingSticker: iconName });
+ try {
+ const { activeProject } = useProjectStore.getState();
+ if (!activeProject) {
+ throw new Error("No active project");
+ }
+
+ const file = await get().downloadSticker(iconName);
+ if (!file) {
+ throw new Error("Failed to download sticker");
+ }
+
+ const mediaItem: Omit<MediaFile, "id"> = {
+ name: iconName.replace(":", "-"),
+ type: "image",
+ file,
+ url: URL.createObjectURL(file),
+ width: 200,
+ height: 200,
+ duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ ephemeral: false,
+ };
+
+ const { addMediaFile } = useMediaStore.getState();
+ await addMediaFile(activeProject.id, mediaItem);
+
+ const added = useMediaStore
+ .getState()
+ .mediaFiles.find(
+ (m) => m.url === mediaItem.url && m.name === mediaItem.name
+ );
+ if (!added) {
+ throw new Error("Sticker not in media store");
+ }
+
+ const { currentTime } = usePlaybackStore.getState();
+ const { addElementAtTime } = useTimelineStore.getState();
+ addElementAtTime(added, currentTime);
+ } finally {
+ set({ addingSticker: null });
+ }
+ },
+
addToRecentStickers: (iconName: string) => {
set((state) => {
const recent = [
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index f0bae00c..aeeb65c5 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -6,19 +6,22 @@ import {
TimelineTrack,
TextElement,
DragData,
+ MediaElement,
sortTracksByOrder,
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
-import { MediaFile } from "@/types/media";
+import { MediaFile, MediaType } from "@/types/media";
import { findBestCanvasPreset } from "@/lib/editor-utils";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
+import { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { usePlaybackStore } from "./playback-store";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@@ -98,11 +101,7 @@ interface TimelineStore {
removeTrack: (trackId: string) => void;
removeTrackWithRipple: (trackId: string) => void;
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
- removeElementFromTrack: (
- trackId: string,
- elementId: string,
- pushHistory?: boolean
- ) => void;
+
moveElementToTrack: (
fromTrackId: string,
toTrackId: string,
@@ -128,15 +127,6 @@ interface TimelineStore {
pushHistory?: boolean
) => void;
toggleTrackMute: (trackId: string) => void;
- toggleElementHidden: (trackId: string, elementId: string) => void;
- toggleElementMuted: (trackId: string, elementId: string) => void;
-
- // Split operations for elements
- splitElement: (
- trackId: string,
- elementId: string,
- splitTime: number
- ) => string | null;
splitAndKeepLeft: (
trackId: string,
elementId: string,
@@ -178,13 +168,52 @@ interface TimelineStore {
pushHistory: () => void;
// Persistence actions
- loadProjectTimeline: (projectId: string) => Promise<void>;
- saveProjectTimeline: (projectId: string) => Promise<void>;
+ loadProjectTimeline: ({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }) => Promise<void>;
+ saveProjectTimeline: ({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }) => Promise<void>;
clearTimeline: () => void;
// Clipboard actions
copySelected: () => void;
pasteAtTime: (time: number) => void;
+
+ // Unified selection-aware actions
+ deleteSelected: (trackId?: string, elementId?: string) => void;
+ splitSelected: (
+ splitTime: number,
+ trackId?: string,
+ elementId?: string
+ ) => void;
+ toggleSelectedHidden: (trackId?: string, elementId?: string) => void;
+ toggleSelectedMuted: (trackId?: string, elementId?: string) => void;
+ duplicateElement: (trackId: string, elementId: string) => void;
+ revealElementInMedia: (elementId: string) => void;
+ replaceElementWithFile: (
+ trackId: string,
+ elementId: string,
+ file: File
+ ) => Promise<void>;
+ getContextMenuState: (
+ trackId: string,
+ elementId: string
+ ) => {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ hasAudioElements: boolean;
+ canSplitSelected: boolean;
+ currentTime: number;
+ };
updateTextElement: (
trackId: string,
elementId: string,
@@ -235,12 +264,27 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
// Helper to auto-save timeline changes
const autoSaveTimeline = async () => {
const activeProject = useProjectStore.getState().activeProject;
- if (activeProject) {
+ const currentScene = useSceneStore.getState().currentScene;
+
+ if (activeProject && currentScene) {
try {
- await storageService.saveTimeline(activeProject.id, get()._tracks);
+ await storageService.saveTimeline({
+ projectId: activeProject.id,
+ tracks: get()._tracks,
+ sceneId: currentScene.id,
+ });
} catch (error) {
console.error("Failed to auto-save timeline:", error);
}
+ } else {
+ console.warn(
+ "Auto-save skipped - missing activeProject or currentScene:",
+ {
+ hasProject: !!activeProject,
+ hasScene: !!currentScene,
+ sceneName: currentScene?.name,
+ }
+ );
}
};
@@ -547,16 +591,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().selectElement(trackId, newElement.id);
},
- removeElementFromTrack: (trackId, elementId, pushHistory = true) => {
- const { rippleEditingEnabled } = get();
+ removeElementFromTrackWithRipple: (
+ trackId,
+ elementId,
+ pushHistory = true
+ ) => {
+ const { _tracks, rippleEditingEnabled } = get();
- if (rippleEditingEnabled) {
- get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory);
- } else {
+ if (!rippleEditingEnabled) {
+ // Inline non-ripple removal logic
if (pushHistory) get().pushHistory();
updateTracksAndSave(
- get()
- ._tracks.map((track) =>
+ _tracks
+ .map((track) =>
track.id === trackId
? {
...track,
@@ -568,18 +615,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
)
.filter((track) => track.elements.length > 0)
);
- }
- },
-
- removeElementFromTrackWithRipple: (
- trackId,
- elementId,
- pushHistory = true
- ) => {
- const { _tracks, rippleEditingEnabled } = get();
-
- if (!rippleEditingEnabled) {
- get().removeElementFromTrack(trackId, elementId, pushHistory);
return;
}
@@ -836,42 +871,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
- toggleElementHidden: (trackId, elementId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, hidden: !element.hidden }
- : element
- ),
- }
- : track
- )
- );
- },
-
- toggleElementMuted: (trackId, elementId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId && element.type === "media"
- ? { ...element, muted: !element.muted }
- : element
- ),
- }
- : track
- )
- );
- },
-
updateTextElement: (trackId, elementId, updates) => {
get().pushHistory();
updateTracksAndSave(
@@ -890,60 +889,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
- splitElement: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
-
- if (!element) return null;
-
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
-
- get().pushHistory();
-
- const relativeTime = splitTime - element.startTime;
- const firstDuration = relativeTime;
- const secondDuration =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
-
- const secondElementId = generateUUID();
-
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.flatMap((c) =>
- c.id === elementId
- ? [
- {
- ...c,
- trimEnd: c.trimEnd + secondDuration,
- name: getElementNameWithSuffix(c.name, "left"),
- },
- {
- ...c,
- id: secondElementId,
- startTime: splitTime,
- trimStart: c.trimStart + firstDuration,
- name: getElementNameWithSuffix(c.name, "right"),
- },
- ]
- : [c]
- ),
- }
- : track
- )
- );
-
- return secondElementId;
- },
-
// Split element and keep only the left portion
splitAndKeepLeft: (trackId, elementId, splitTime) => {
const { _tracks } = get();
@@ -1122,9 +1067,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
}
- const mediaData: any = {
+ const mediaData: Omit<MediaFile, "id"> = {
name: newFile.name,
- type: fileType,
+ type: fileType as MediaType,
file: newFile,
url: URL.createObjectURL(newFile),
};
@@ -1229,8 +1174,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
getProjectThumbnail: async (projectId) => {
try {
- const tracks = await storageService.loadTimeline(projectId);
- const mediaItems = await storageService.loadAllMediaFiles(projectId);
+ const project = await storageService.loadProject({ id: projectId });
+ if (!project) return null;
+
+ // For scene-based projects, use main scene timeline
+ // For legacy projects, use legacy timeline format
+ let sceneId: string | undefined;
+ if (project.scenes && project.scenes.length > 0) {
+ const mainScene = project.scenes.find((s) => s.isMain);
+ sceneId = mainScene?.id;
+ }
+
+ const tracks = await storageService.loadTimeline({
+ projectId,
+ sceneId,
+ });
+ const mediaItems = await storageService.loadAllMediaFiles({
+ projectId,
+ });
if (!tracks || !mediaItems.length) return null;
@@ -1330,9 +1291,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
});
},
- loadProjectTimeline: async (projectId) => {
+ loadProjectTimeline: async ({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }) => {
try {
- const tracks = await storageService.loadTimeline(projectId);
+ const tracks = await storageService.loadTimeline({
+ projectId,
+ sceneId,
+ });
+
if (tracks) {
updateTracks(tracks);
} else {
@@ -1348,9 +1319,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
},
- saveProjectTimeline: async (projectId) => {
+ saveProjectTimeline: async ({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }) => {
+ const { _tracks } = get();
try {
- await storageService.saveTimeline(projectId, get()._tracks);
+ await storageService.saveTimeline({
+ projectId,
+ tracks: _tracks,
+ sceneId,
+ });
} catch (error) {
console.error("Failed to save timeline:", error);
}
@@ -1541,6 +1523,332 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
}
},
+
+ deleteSelected: (trackId?: string, elementId?: string) => {
+ const { selectedElements, rippleEditingEnabled } = get();
+
+ const elementsToDelete =
+ trackId && elementId
+ ? [{ trackId, elementId }]
+ : selectedElements.length > 0
+ ? selectedElements
+ : [];
+
+ if (elementsToDelete.length === 0) return;
+
+ get().pushHistory();
+
+ if (rippleEditingEnabled) {
+ for (const { trackId: tId, elementId: eId } of elementsToDelete) {
+ get().removeElementFromTrackWithRipple(tId, eId, false);
+ }
+ } else {
+ updateTracksAndSave(
+ get()
+ ._tracks.map((track) => ({
+ ...track,
+ elements: track.elements.filter(
+ (element) =>
+ !elementsToDelete.some(
+ ({ trackId: tId, elementId: eId }) =>
+ track.id === tId && element.id === eId
+ )
+ ),
+ }))
+ .filter((track) => track.elements.length > 0)
+ );
+ }
+
+ get().clearSelectedElements();
+ },
+
+ splitSelected: (splitTime, trackId?: string, elementId?: string) => {
+ const { selectedElements, _tracks } = get();
+
+ const elementsToProcess =
+ trackId && elementId
+ ? [{ trackId, elementId }]
+ : selectedElements.length > 0
+ ? selectedElements
+ : [];
+
+ if (elementsToProcess.length === 0) return;
+
+ const elementsToSplit: Array<{
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ }> = [];
+
+ for (const { trackId: tId, elementId: eId } of elementsToProcess) {
+ const track = _tracks.find((t) => t.id === tId);
+ const element = track?.elements.find((e) => e.id === eId);
+ if (!track || !element) continue;
+
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (splitTime > effectiveStart && splitTime < effectiveEnd) {
+ elementsToSplit.push({ trackId: tId, elementId: eId, element });
+ }
+ }
+
+ if (elementsToSplit.length === 0) {
+ const { toast } = require("sonner");
+ const isMultiple = elementsToProcess.length > 1;
+ toast.error(
+ isMultiple
+ ? "Playhead must be within all selected elements to split"
+ : "Playhead must be within element to split"
+ );
+ return;
+ }
+
+ get().pushHistory();
+
+ updateTracksAndSave(
+ get()._tracks.map((track) => {
+ const elementsToSplitInTrack = elementsToSplit.filter(
+ ({ trackId: tId }) => tId === track.id
+ );
+
+ if (elementsToSplitInTrack.length === 0) return track;
+
+ return {
+ ...track,
+ elements: track.elements.flatMap((c) => {
+ const elementToSplit = elementsToSplitInTrack.find(
+ ({ elementId: eId }) => eId === c.id
+ );
+
+ if (!elementToSplit) return [c];
+
+ const relativeTime = splitTime - elementToSplit.element.startTime;
+ const firstDuration = relativeTime;
+ const secondDuration =
+ elementToSplit.element.duration -
+ elementToSplit.element.trimStart -
+ elementToSplit.element.trimEnd -
+ relativeTime;
+
+ const secondElementId = generateUUID();
+
+ return [
+ {
+ ...c,
+ trimEnd: c.trimEnd + secondDuration,
+ name: getElementNameWithSuffix(c.name, "left"),
+ },
+ {
+ ...c,
+ id: secondElementId,
+ startTime: splitTime,
+ trimStart: c.trimStart + firstDuration,
+ name: getElementNameWithSuffix(c.name, "right"),
+ },
+ ];
+ }),
+ };
+ })
+ );
+ },
+
+ toggleSelectedHidden: (trackId?: string, elementId?: string) => {
+ const { selectedElements, _tracks } = get();
+
+ const elementsToProcess =
+ trackId && elementId
+ ? [{ trackId, elementId }]
+ : selectedElements.length > 0
+ ? selectedElements
+ : [];
+
+ if (elementsToProcess.length === 0) return;
+
+ get().pushHistory();
+
+ const shouldHide = elementsToProcess.some(
+ ({ trackId: tId, elementId: eId }) => {
+ const track = _tracks.find((t) => t.id === tId);
+ const element = track?.elements.find((e) => e.id === eId);
+ return element && !element.hidden;
+ }
+ );
+
+ updateTracksAndSave(
+ _tracks.map((track) => ({
+ ...track,
+ elements: track.elements.map((element) => {
+ const shouldUpdate = elementsToProcess.some(
+ ({ trackId: tId, elementId: eId }) =>
+ track.id === tId && element.id === eId
+ );
+ return shouldUpdate && element.hidden !== shouldHide
+ ? { ...element, hidden: shouldHide }
+ : element;
+ }),
+ }))
+ );
+ },
+
+ toggleSelectedMuted: (trackId?: string, elementId?: string) => {
+ const { selectedElements, _tracks } = get();
+
+ const elementsToProcess =
+ trackId && elementId
+ ? [{ trackId, elementId }]
+ : selectedElements.length > 0
+ ? selectedElements
+ : [];
+
+ if (elementsToProcess.length === 0) return;
+
+ get().pushHistory();
+
+ const audioElements = elementsToProcess.filter(
+ ({ trackId: tId, elementId: eId }) => {
+ const track = _tracks.find((t) => t.id === tId);
+ const element = track?.elements.find((e) => e.id === eId);
+ return element?.type === "media";
+ }
+ );
+
+ if (audioElements.length === 0) return;
+
+ const shouldMute = audioElements.some(
+ ({ trackId: tId, elementId: eId }) => {
+ const track = _tracks.find((t) => t.id === tId);
+ const element = track?.elements.find((e) => e.id === eId);
+ return element?.type === "media" && !element.muted;
+ }
+ );
+
+ updateTracksAndSave(
+ _tracks.map((track) => ({
+ ...track,
+ elements: track.elements.map((element) => {
+ const shouldUpdate = audioElements.some(
+ ({ trackId: tId, elementId: eId }) =>
+ track.id === tId && element.id === eId
+ );
+ return shouldUpdate &&
+ element.type === "media" &&
+ element.muted !== shouldMute
+ ? { ...element, muted: shouldMute }
+ : element;
+ }),
+ }))
+ );
+ },
+
+ duplicateElement: (trackId, elementId) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ if (!track || !element) return;
+
+ const { id, ...elementWithoutId } = element;
+ const effectiveDuration =
+ element.duration - element.trimStart - element.trimEnd;
+
+ get().addElementToTrack(trackId, {
+ ...elementWithoutId,
+ name: `${element.name} (copy)`,
+ startTime: element.startTime + effectiveDuration + 0.1,
+ } as CreateTimelineElement);
+ },
+
+ revealElementInMedia: (elementId) => {
+ const {
+ useMediaPanelStore,
+ } = require("../components/editor/media-panel/store");
+ const { requestRevealMedia } = useMediaPanelStore.getState();
+
+ const { _tracks } = get();
+ const element = _tracks
+ .flatMap((track) => track.elements)
+ .find((el) => el.id === elementId);
+
+ if (element?.type === "media") {
+ requestRevealMedia(element.mediaId);
+ }
+ },
+
+ replaceElementWithFile: async (trackId, elementId, file) => {
+ try {
+ const result = await get().replaceElementMedia(
+ trackId,
+ elementId,
+ file
+ );
+ if (result.success) {
+ const { toast } = await import("sonner");
+ toast.success("Clip replaced successfully");
+ } else {
+ const { toast } = await import("sonner");
+ toast.error(result.error || "Failed to replace clip");
+ }
+ } catch (error) {
+ console.error("Unexpected error replacing clip:", error);
+ const { toast } = await import("sonner");
+ toast.error(
+ `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
+ );
+ }
+ },
+
+ getContextMenuState: (trackId, elementId) => {
+ const { selectedElements, _tracks } = get();
+ const { currentTime } = usePlaybackStore.getState();
+ const { mediaFiles } = useMediaStore.getState();
+
+ const isMultipleSelected = selectedElements.length > 1;
+ const isCurrentElementSelected = selectedElements.some(
+ (sel) => sel.trackId === trackId && sel.elementId === elementId
+ );
+
+ const hasAudioElements = selectedElements.some(
+ ({ trackId: tId, elementId: eId }) => {
+ const selectedTrack = _tracks.find((t) => t.id === tId);
+ const selectedElement = selectedTrack?.elements.find(
+ (e) => e.id === eId
+ );
+ if (selectedElement?.type !== "media") return false;
+ const mediaElement = selectedElement as MediaElement;
+ const mediaItem = mediaFiles.find(
+ (file: MediaFile) => file.id === mediaElement.mediaId
+ );
+ return mediaItem?.type === "audio" || mediaItem?.type === "video";
+ }
+ );
+
+ const canSplitSelected = selectedElements.every(
+ ({ trackId: tId, elementId: eId }) => {
+ const selectedTrack = _tracks.find((t) => t.id === tId);
+ const selectedElement = selectedTrack?.elements.find(
+ (e) => e.id === eId
+ );
+ if (!selectedElement) return false;
+ const effectiveStart = selectedElement.startTime;
+ const effectiveEnd =
+ selectedElement.startTime +
+ (selectedElement.duration -
+ selectedElement.trimStart -
+ selectedElement.trimEnd);
+ return currentTime > effectiveStart && currentTime < effectiveEnd;
+ }
+ );
+
+ return {
+ isMultipleSelected,
+ isCurrentElementSelected,
+ hasAudioElements,
+ canSplitSelected,
+ currentTime,
+ };
+ },
};
});
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index bf510942..f182653d 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -1,15 +1,27 @@
import { CanvasSize } from "./editor";
+export type BlurIntensity = 4 | 8 | 18;
+
+export interface Scene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
export interface TProject {
id: string;
name: string;
thumbnail: string;
createdAt: Date;
updatedAt: Date;
+ scenes: Scene[];
+ currentSceneId: string;
mediaItems?: string[];
backgroundColor?: string;
backgroundType?: "color" | "blur";
- blurIntensity?: number; // in pixels (4, 8, 18)
+ blurIntensity?: BlurIntensity;
fps?: number;
bookmarks?: number[];
canvasSize: CanvasSize;