Merge branch 'staging'
This commit is contained in:
commit
60debe2ce5
|
|
@ -5,15 +5,18 @@ alwaysApply: true
|
|||
---
|
||||
|
||||
# Project Context
|
||||
|
||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Zero configuration required
|
||||
- Subsecond performance
|
||||
- Maximum type safety
|
||||
- AI-friendly code generation
|
||||
|
||||
## Before Writing Code
|
||||
|
||||
1. Analyze existing patterns in the codebase
|
||||
2. Consider edge cases and error scenarios
|
||||
3. Follow the rules below strictly
|
||||
|
|
@ -23,12 +26,14 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
## Rules
|
||||
|
||||
### Accessibility (a11y)
|
||||
|
||||
- Always include a `title` element for icons unless there's text beside the icon.
|
||||
- Always include a `type` attribute for button elements.
|
||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||
|
||||
### Code Complexity and Quality
|
||||
|
||||
- Don't use primitive type aliases or misleading types.
|
||||
- Don't use the comma operator.
|
||||
- Use for...of statements instead of Array.forEach.
|
||||
|
|
@ -36,6 +41,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Use .flatMap() instead of map().flat() when possible.
|
||||
|
||||
### React and JSX Best Practices
|
||||
|
||||
- Don't import `React` itself.
|
||||
- Don't define React components inside other components.
|
||||
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
||||
|
|
@ -43,11 +49,13 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
||||
|
||||
### Function Parameters and Props
|
||||
|
||||
- Always use destructured props objects instead of individual parameters in functions.
|
||||
- Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`.
|
||||
- This applies to all functions, not just React components.
|
||||
|
||||
### Correctness and Safety
|
||||
|
||||
- Don't assign a value to itself.
|
||||
- Avoid unused imports and variables.
|
||||
- Don't use await inside loops.
|
||||
|
|
@ -55,13 +63,16 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Don't use the TypeScript directive @ts-ignore.
|
||||
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
||||
- Don't use the `delete` operator.
|
||||
- Don't use `require()` in TypeScript/ES modules - use proper `import` statements.
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
- Don't use TypeScript enums.
|
||||
- Use either `T[]` or `Array<T>` consistently.
|
||||
- Don't use the `any` type.
|
||||
|
||||
### Style and Consistency
|
||||
|
||||
- Don't use global `eval()`.
|
||||
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
||||
- Don't use `else` blocks when the `if` block breaks early.
|
||||
|
|
@ -70,17 +81,19 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
||||
|
||||
### Next.js Specific Rules
|
||||
|
||||
- Don't use `<img>` elements in Next.js projects.
|
||||
- Don't use `<head>` elements in Next.js projects.
|
||||
|
||||
## Example: Error Handling
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Comprehensive error handling
|
||||
try {
|
||||
const result = await fetchData();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
console.error("API call failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
DEFAULT_EXPORT_OPTIONS,
|
||||
} from "@/lib/export";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
|
||||
import { PropertyGroup } from "./properties-panel/property-item";
|
||||
|
||||
|
|
@ -132,137 +132,186 @@ function ExportPopover({
|
|||
};
|
||||
|
||||
return (
|
||||
<PopoverContent className="w-80 mr-4 flex flex-col gap-3">
|
||||
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className=" font-medium">
|
||||
{isExporting ? "Exporting project" : "Export project"}
|
||||
</h3>
|
||||
<Button variant="text" size="icon" onClick={handleClose}>
|
||||
<X className="!size-5 text-foreground/85" />
|
||||
</Button>
|
||||
</div>
|
||||
{exportResult && !exportResult.success ? (
|
||||
<ExportError
|
||||
error={exportResult.error || "Unknown error occurred"}
|
||||
onRetry={handleExport}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className=" font-medium">
|
||||
{isExporting ? "Exporting project" : "Export project"}
|
||||
</h3>
|
||||
<Button variant="text" size="icon" onClick={handleClose}>
|
||||
<X className="!size-5 text-foreground/85" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isExporting && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<PropertyGroup
|
||||
title="Format"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => setFormat(value as ExportFormat)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mp4" id="mp4" />
|
||||
<Label htmlFor="mp4">
|
||||
MP4 (H.264) - Better compatibility
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="webm" id="webm" />
|
||||
<Label htmlFor="webm">
|
||||
WebM (VP9) - Smaller file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isExporting && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<PropertyGroup
|
||||
title="Format"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) =>
|
||||
setFormat(value as ExportFormat)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mp4" id="mp4" />
|
||||
<Label htmlFor="mp4">
|
||||
MP4 (H.264) - Better compatibility
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="webm" id="webm" />
|
||||
<Label htmlFor="webm">
|
||||
WebM (VP9) - Smaller file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup
|
||||
title="Quality"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) =>
|
||||
setQuality(value as ExportQuality)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="low" id="low" />
|
||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="medium" id="medium" />
|
||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="high" id="high" />
|
||||
<Label htmlFor="high">High - Recommended</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="very_high" id="very_high" />
|
||||
<Label htmlFor="very_high">
|
||||
Very High - Largest file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup
|
||||
title="Quality"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) =>
|
||||
setQuality(value as ExportQuality)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="low" id="low" />
|
||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="medium" id="medium" />
|
||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="high" id="high" />
|
||||
<Label htmlFor="high">High - Recommended</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="very_high" id="very_high" />
|
||||
<Label htmlFor="very_high">
|
||||
Very High - Largest file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup
|
||||
title="Audio"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) => setIncludeAudio(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
<PropertyGroup
|
||||
title="Audio"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isExporting && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-center flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{Math.round(progress * 100)}%
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">100%</p>
|
||||
{isExporting && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-center flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{Math.round(progress * 100)}%
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">100%</p>
|
||||
</div>
|
||||
<Progress value={progress * 100} className="w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-md w-full"
|
||||
onClick={() => {}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={progress * 100} className="w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-md w-full"
|
||||
onClick={() => {}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportResult && !exportResult.success && (
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-red-600 font-medium">Export failed</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{exportResult.error || "Unknown error occurred"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setExportResult(null)}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportError({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(error);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium text-red-400">Export failed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-8"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="text-green-500" /> : <Copy />}
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-8"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RotateCcw />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { renderTimelineFrame } from "@/lib/timeline-renderer";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||
import { useFrameCache } from "@/hooks/use-frame-cache";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
import {
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_FPS,
|
||||
|
|
@ -42,8 +44,11 @@ export function PreviewPanel() {
|
|||
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
|
||||
const { isPlaying, volume, muted } = usePlaybackStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { currentScene } = useSceneStore();
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
|
||||
useFrameCache();
|
||||
const lastFrameTimeRef = useRef(0);
|
||||
const renderSeqRef = useRef(0);
|
||||
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
|
||||
|
|
@ -216,6 +221,16 @@ export function PreviewPanel() {
|
|||
};
|
||||
}, [dragState, previewDimensions, canvasSize, updateTextElement]);
|
||||
|
||||
// Clear the frame cache when background settings change since they affect rendering
|
||||
useEffect(() => {
|
||||
invalidateCache();
|
||||
}, [
|
||||
mediaFiles,
|
||||
activeProject?.backgroundColor,
|
||||
activeProject?.backgroundType,
|
||||
invalidateCache,
|
||||
]);
|
||||
|
||||
const handleTextMouseDown = (
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
element: any,
|
||||
|
|
@ -246,6 +261,7 @@ export function PreviewPanel() {
|
|||
}, []);
|
||||
|
||||
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
|
||||
const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType;
|
||||
const getActiveElements = (): ActiveElement[] => {
|
||||
const activeElements: ActiveElement[] = [];
|
||||
|
||||
|
|
@ -449,7 +465,7 @@ export function PreviewPanel() {
|
|||
};
|
||||
}, [isPlaying, volume, muted, mediaFiles]);
|
||||
|
||||
// Canvas: draw current frame for visible elements using offscreen compositing
|
||||
// Canvas: draw current frame with caching
|
||||
useEffect(() => {
|
||||
const draw = async () => {
|
||||
const canvas = canvasRef.current;
|
||||
|
|
@ -475,10 +491,94 @@ export function PreviewPanel() {
|
|||
lastFrameTimeRef.current = currentTime;
|
||||
}
|
||||
|
||||
// Invalidate older async renders when user scrubs rapidly
|
||||
const mySeq = (renderSeqRef.current += 1);
|
||||
const cachedFrame = getCachedFrame(
|
||||
currentTime,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
currentScene?.id
|
||||
);
|
||||
if (cachedFrame) {
|
||||
mainCtx.putImageData(cachedFrame, 0, 0);
|
||||
|
||||
// Offscreen buffer to avoid flicker (reuse canvas)
|
||||
// Pre-render nearby frames in background
|
||||
if (!isPlaying) {
|
||||
// Only during scrubbing to avoid interfering with playback
|
||||
preRenderNearbyFrames(
|
||||
currentTime,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
async (time: number) => {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = displayWidth;
|
||||
tempCanvas.height = displayHeight;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
if (!tempCtx)
|
||||
throw new Error("Failed to create temp canvas context");
|
||||
|
||||
await renderTimelineFrame({
|
||||
ctx: tempCtx,
|
||||
time,
|
||||
canvasWidth: displayWidth,
|
||||
canvasHeight: displayHeight,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
backgroundType: activeProject?.backgroundType,
|
||||
blurIntensity: activeProject?.blurIntensity,
|
||||
backgroundColor:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? undefined
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
projectCanvasSize: canvasSize,
|
||||
});
|
||||
|
||||
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
|
||||
},
|
||||
currentScene?.id,
|
||||
3
|
||||
);
|
||||
} else {
|
||||
// Small lookahead while playing
|
||||
preRenderNearbyFrames(
|
||||
currentTime,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
async (time: number) => {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = displayWidth;
|
||||
tempCanvas.height = displayHeight;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
if (!tempCtx)
|
||||
throw new Error("Failed to create temp canvas context");
|
||||
|
||||
await renderTimelineFrame({
|
||||
ctx: tempCtx,
|
||||
time,
|
||||
canvasWidth: displayWidth,
|
||||
canvasHeight: displayHeight,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
backgroundType: activeProject?.backgroundType,
|
||||
blurIntensity: activeProject?.blurIntensity,
|
||||
backgroundColor:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? undefined
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
projectCanvasSize: canvasSize,
|
||||
});
|
||||
|
||||
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
|
||||
},
|
||||
currentScene?.id,
|
||||
1
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache miss - render from scratch
|
||||
if (!offscreenCanvasRef.current) {
|
||||
const hasOffscreen =
|
||||
typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
|
||||
|
|
@ -534,13 +634,30 @@ export function PreviewPanel() {
|
|||
canvasHeight: displayHeight,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
backgroundType: activeProject?.backgroundType,
|
||||
blurIntensity: activeProject?.blurIntensity,
|
||||
backgroundColor:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? "transparent"
|
||||
? undefined
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
projectCanvasSize: canvasSize,
|
||||
});
|
||||
|
||||
const imageData = (offCtx as CanvasRenderingContext2D).getImageData(
|
||||
0,
|
||||
0,
|
||||
displayWidth,
|
||||
displayHeight
|
||||
);
|
||||
cacheFrame(
|
||||
currentTime,
|
||||
imageData,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
currentScene?.id
|
||||
);
|
||||
|
||||
// Blit offscreen to visible canvas
|
||||
mainCtx.clearRect(0, 0, displayWidth, displayHeight);
|
||||
if ((offscreenCanvas as HTMLCanvasElement).getContext) {
|
||||
|
|
@ -564,6 +681,10 @@ export function PreviewPanel() {
|
|||
canvasSize.height,
|
||||
activeProject?.backgroundType,
|
||||
activeProject?.backgroundColor,
|
||||
getCachedFrame,
|
||||
cacheFrame,
|
||||
preRenderNearbyFrames,
|
||||
isPlaying,
|
||||
]);
|
||||
|
||||
// Get media elements for blur background (video/image only)
|
||||
|
|
@ -593,7 +714,7 @@ export function PreviewPanel() {
|
|||
className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0"
|
||||
>
|
||||
<div className="flex-1" />
|
||||
{hasAnyElements ? (
|
||||
{shouldRenderPreview ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden border"
|
||||
|
|
@ -619,9 +740,7 @@ export function PreviewPanel() {
|
|||
aria-label="Video preview canvas"
|
||||
/>
|
||||
{activeElements.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
No elements at current time
|
||||
</div>
|
||||
<></>
|
||||
) : (
|
||||
activeElements.map((elementData) => renderElement(elementData))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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)",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue