This commit is contained in:
Maze Winther 2026-01-05 01:35:10 +01:00
parent d5ca991501
commit c19f085e48
95 changed files with 4135 additions and 4198 deletions

59
AGENTS.md Normal file
View File

@ -0,0 +1,59 @@
# AGENTS.md
## Overview
Privacy-first video editor, with a focus on simplicity and ease of use.
## Core Editor System
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
### Architecture
```
EditorCore (singleton)
├── playback: PlaybackManager
├── timeline: TimelineManager
├── scene: SceneManager
├── project: ProjectManager
├── media: MediaManager
└── renderer: RendererManager
```
### When to Use What
#### In React Components
**Always use the `useEditor()` hook:**
```typescript
import { useEditor } from '@/hooks/use-editor';
function MyComponent() {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
// Call methods
editor.timeline.addTrack({ type: 'media' });
// Display data (auto re-renders on changes)
return <div>{tracks.length} tracks</div>;
}
```
The hook:
- Returns the singleton instance
- Subscribes to all manager changes
- Automatically re-renders when state changes
#### Outside React Components
**Use `EditorCore.getInstance()` directly:**
```typescript
// In utilities, event handlers, or non-React code
import { EditorCore } from '@/core';
const editor = EditorCore.getInstance();
await editor.export({ format: 'mp4', quality: 'high' });
```

6
TODO.md Normal file
View File

@ -0,0 +1,6 @@
[x] Delete all stores we can
[x] Create commands for each action
[x] Finish timeline store
[x] Migrate final timeline store to manager
[x] Delete timeline store
[ ] Update usage of all stores to use the new managers

4
apps/web/.gitignore vendored
View File

@ -3,4 +3,6 @@
# Env vars
.env*
!.env.example
!.env.example
.next/

View File

@ -22,7 +22,6 @@
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@opencut/env": "workspace:*",
"@opencut/hooks": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-separator": "^1.1.7",
"@upstash/ratelimit": "^2.0.6",

View File

@ -24,6 +24,8 @@
--accent-foreground: hsl(0 0% 2%);
--destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%);
--input: hsl(0 0% 85.1%);
--ring: hsl(0, 0%, 55%);
@ -62,6 +64,8 @@
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 100% 60%);
--destructive-foreground: hsl(0 0% 98%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 17%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
@ -152,6 +156,9 @@
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);

View File

@ -36,25 +36,12 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { useProjectStore } from "@/stores/project-store";
import { EditorCore } from "@/core";
import { useTimelineStore } from "@/stores/timeline-store";
import type { TProject } from "@/types/project";
import { toast } from "sonner";
export default function ProjectsPage() {
const {
savedProjects,
isLoading,
isInitialized,
deleteProject,
createNewProject,
getFilteredAndSortedProjects,
} = useProjectStore();
const [thumbnailCache, setThumbnailCache] = useState<
Record<string, string | null>
>({});
const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
new Set(),
);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
@ -62,36 +49,21 @@ export default function ProjectsPage() {
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const { getProjectThumbnail } = useTimelineStore();
const router = useRouter();
const getProjectThumbnail = useCallback(
async (projectId: string): Promise<string | null> => {
if (thumbnailCache[projectId] !== undefined) {
return thumbnailCache[projectId];
}
setLoadingThumbnails((prev) => new Set(prev).add(projectId));
try {
const thumbnail = await getProjectThumbnail(projectId);
setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
return thumbnail;
} finally {
setLoadingThumbnails((prev) => {
const newSet = new Set(prev);
newSet.delete(projectId);
return newSet;
});
}
},
[],
);
const editor = EditorCore.getInstance();
const handleCreateProject = async () => {
const projectId = await createNewProject("New Project");
console.log("projectId", projectId);
router.push(`/editor/${projectId}`);
try {
const projectId = await editor.project.createNewProject({
name: "New project",
});
router.push(`/editor/${projectId}`);
} catch (error) {
toast.error("Failed to create project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const handleSelectProject = (projectId: string, checked: boolean) => {
@ -118,15 +90,28 @@ export default function ProjectsPage() {
};
const handleBulkDelete = async () => {
await Promise.all(
Array.from(selectedProjects).map((projectId) => deleteProject(projectId)),
);
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
const sortedProjects = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
const allSelected =
sortedProjects.length > 0 &&
@ -178,8 +163,8 @@ export default function ProjectsPage() {
Your Projects
</h1>
<p className="text-muted-foreground">
{savedProjects.length}{" "}
{savedProjects.length === 1 ? "project" : "projects"}
{sortedProjects.length}{" "}
{sortedProjects.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
@ -209,9 +194,9 @@ export default function ProjectsPage() {
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={savedProjects.length === 0}
disabled={sortedProjects.length === 0}
>
Select Projects
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
@ -317,7 +302,7 @@ export default function ProjectsPage() {
</button>
)}
{isLoading || !isInitialized ? (
{editor.project.isLoading || !editor.project.isInitialized ? (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<div
@ -335,7 +320,7 @@ export default function ProjectsPage() {
</div>
))}
</div>
) : savedProjects.length === 0 ? (
) : sortedProjects.length === 0 ? (
<NoProjects onCreateProject={handleCreateProject} />
) : sortedProjects.length === 0 ? (
<NoResults
@ -351,7 +336,6 @@ export default function ProjectsPage() {
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
getProjectThumbnail={getProjectThumbnail}
/>
))}
</div>
@ -372,7 +356,6 @@ interface ProjectCardProps {
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: (projectId: string, checked: boolean) => void;
getProjectThumbnail: (projectId: string) => Promise<string | null>;
}
function ProjectCard({
@ -380,28 +363,12 @@ function ProjectCard({
isSelectionMode = false,
isSelected = false,
onSelect,
getProjectThumbnail,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
useEffect(() => {
const loadThumbnail = async () => {
setIsLoadingThumbnail(true);
try {
const thumbnail = await getProjectThumbnail(project.id);
setDynamicThumbnail(thumbnail);
} finally {
setIsLoadingThumbnail(false);
}
};
loadThumbnail();
}, [project.id, getProjectThumbnail]);
const formatDate = (date: Date): string => {
return date.toLocaleDateString("en-US", {
month: "short",
@ -466,13 +433,9 @@ function ProjectCard({
)}
<div className="absolute inset-0">
{isLoadingThumbnail ? (
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
<Loader2 className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
) : dynamicThumbnail ? (
{project.thumbnail ? (
<Image
src={dynamicThumbnail}
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"

View File

@ -1,9 +1,9 @@
"use client";
import { useFileUpload } from "@opencut/hooks/use-file-upload";
import { useFileUpload } from "@/hooks/use-file-upload";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import { MediaFile } from "@/types/assets";
import {
ArrowDown01,
CloudUpload,

View File

@ -1,8 +1,14 @@
"use client";
import { Button } from "../ui/button";
import { ChevronDown, ArrowLeft, SquarePen, Trash } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import {
ChevronDown,
ArrowLeft,
SquarePen,
Trash,
Loader2,
} from "lucide-react";
import { EditorCore } from "@/core";
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
import { useState } from "react";
import {
@ -21,6 +27,7 @@ import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
export function EditorHeader() {
return (
@ -39,28 +46,59 @@ export function EditorHeader() {
}
function ProjectDropdown() {
const { activeProject, renameProject, deleteProject } = useProjectStore();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
const handleNameSave = async (newName: string) => {
console.log("handleNameSave", newName);
const handleExit = async () => {
if (isExiting) return;
setIsExiting(true);
try {
await editor.project.prepareExit();
editor.project.closeProject();
} catch (error) {
console.error("Failed to prepare project exit:", error);
} finally {
editor.project.closeProject();
router.push("/projects");
}
};
const handleSaveProjectName = async (newName: string) => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
setIsRenameDialogOpen(false);
await editor.project.renameProject({
id: activeProject.id,
name: newName.trim(),
});
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsRenameDialogOpen(false);
}
}
};
const handleDelete = () => {
const handleDeleteProject = async () => {
if (activeProject) {
deleteProject(activeProject.id);
setIsDeleteDialogOpen(false);
router.push("/projects");
try {
await editor.project.deleteProject({ id: activeProject.id });
router.push("/projects");
} catch (error) {
toast.error("Failed to delete project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsDeleteDialogOpen(false);
}
}
};
@ -77,12 +115,18 @@ function ProjectDropdown() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-100 w-40">
<Link href="/projects">
<DropdownMenuItem className="flex items-center gap-1.5">
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={handleExit}
disabled={isExiting}
>
{isExiting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowLeft className="h-4 w-4" />
Projects
</DropdownMenuItem>
</Link>
)}
Projects
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setIsRenameDialogOpen(true)}
@ -115,13 +159,13 @@ function ProjectDropdown() {
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={handleNameSave}
onConfirm={(newName) => handleSaveProjectName(newName)}
projectName={activeProject?.name || ""}
/>
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDelete}
onConfirm={handleDeleteProject}
projectName={activeProject?.name || ""}
/>
</>

View File

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { TransitionUpIcon } from "../icons";
import { TransitionUpIcon } from "@opencut/ui/icons";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
@ -15,20 +15,20 @@ import {
getExportFileExtension,
DEFAULT_EXPORT_OPTIONS,
} from "@/lib/export-utils";
import { useProjectStore } from "@/stores/project-store";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
import { useEditor } from "@/hooks/use-editor";
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const { activeProject } = useProjectStore();
const editor = useEditor();
const handleExport = () => {
setIsExportPopoverOpen(true);
};
const hasProject = !!activeProject;
const hasProject = !!editor.project.activeProject;
return (
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
@ -36,10 +36,10 @@ export function ExportButton() {
<button
type="button"
className={cn(
"flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200",
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white transition-all duration-200",
hasProject
? "cursor-pointer hover:brightness-95"
: "cursor-not-allowed opacity-50"
: "cursor-not-allowed opacity-50",
)}
onClick={hasProject ? handleExport : undefined}
disabled={!hasProject}
@ -50,11 +50,11 @@ export function ExportButton() {
}
}}
>
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<div className="bg-linear-270 relative flex items-center gap-1.5 rounded-[0.8rem] from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<TransitionUpIcon className="z-50" />
<span className="text-[0.875rem] z-50">Export</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div>
<span className="z-50 text-[0.875rem]">Export</span>
<div className="bg-linear-to-t absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center rounded-[0.8rem] from-white/0 to-white/50">
<div className="bg-linear-270 absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.8rem] from-[#2567EC] to-[#37B6F7]"></div>
</div>
</div>
</button>
@ -69,15 +69,16 @@ function ExportPopover({
}: {
onOpenChange: (open: boolean) => void;
}) {
const { activeProject } = useProjectStore();
const editor = useEditor();
const activeProject = editor.project.activeProject;
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format
DEFAULT_EXPORT_OPTIONS.format,
);
const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality
DEFAULT_EXPORT_OPTIONS.quality,
);
const [includeAudio, setIncludeAudio] = useState<boolean>(
DEFAULT_EXPORT_OPTIONS.includeAudio || true
DEFAULT_EXPORT_OPTIONS.includeAudio || true,
);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
@ -104,8 +105,8 @@ function ExportPopover({
if (result.success && result.buffer) {
// Download the file
const mimeType = getExportMimeType(format);
const extension = getExportFileExtension(format);
const mimeType = getExportMimeType({ format });
const extension = getExportFileExtension({ format });
const blob = new Blob([result.buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
@ -132,7 +133,7 @@ function ExportPopover({
};
return (
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
<>
{exportResult && !exportResult.success ? (
<ExportError
@ -142,11 +143,11 @@ function ExportPopover({
) : (
<>
<div className="flex items-center justify-between">
<h3 className=" font-medium">
<h3 className="font-medium">
{isExporting ? "Exporting project" : "Export project"}
</h3>
<Button variant="text" size="icon" onClick={handleClose}>
<X className="!size-5 text-foreground/85" />
<X className="text-foreground/85 !size-5" />
</Button>
</div>
@ -233,7 +234,7 @@ function ExportPopover({
</div>
<Button onClick={handleExport} className="w-full gap-2">
<Download className="w-4 h-4" />
<Download className="h-4 w-4" />
Export
</Button>
</>
@ -242,18 +243,18 @@ function ExportPopover({
{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">
<div className="flex items-center justify-between text-center">
<p className="text-muted-foreground mb-2 text-sm">
{Math.round(progress * 100)}%
</p>
<p className="text-sm text-muted-foreground mb-2">100%</p>
<p className="text-muted-foreground mb-2 text-sm">100%</p>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button
variant="outline"
className="rounded-md w-full"
className="w-full rounded-md"
onClick={() => {}}
>
Cancel
@ -286,26 +287,24 @@ function ExportError({
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>
<p className="text-sm font-medium text-destructive">Export failed</p>
<p className="text-muted-foreground text-xs">{error}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-8"
className="h-8 flex-1 text-xs"
onClick={handleCopy}
>
{copied ? <Check className="text-green-500" /> : <Copy />}
{copied ? <Check className="text-constructive" /> : <Copy />}
Copy
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-8"
className="h-8 flex-1 text-xs"
onClick={onRetry}
>
<RotateCcw />

View File

@ -1,5 +1,5 @@
import { MediaElement } from "@/types/timeline";
import { AudioElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: MediaElement }) {
export function AudioProperties({ element }: { element: AudioElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}

View File

@ -1,5 +1,9 @@
import { MediaElement } from "@/types/timeline";
import { VideoElement, ImageElement } from "@/types/timeline";
export function MediaProperties({ element }: { element: MediaElement }) {
export function MediaProperties({
element,
}: {
element: VideoElement | ImageElement;
}) {
return <div className="space-y-4 p-5">Media properties</div>;
}

View File

@ -20,22 +20,17 @@ export function SelectionBox({
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const containerRect = containerRef.current.getBoundingClientRect();
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
@ -49,7 +44,7 @@ export function SelectionBox({
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50 bg-foreground/10"
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/>
);
}

View File

@ -1,46 +0,0 @@
import { Slider } from "../ui/slider";
import { Label } from "../ui/label";
import { Button } from "../ui/button";
import { usePlaybackStore } from "@/stores/playback-store";
const SPEED_PRESETS = [
{ label: "0.5x", value: 0.5 },
{ label: "1x", value: 1.0 },
{ label: "1.5x", value: 1.5 },
{ label: "2x", value: 2.0 },
];
export function SpeedControl() {
const { speed, setSpeed } = usePlaybackStore();
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">Playback Speed</h3>
<div className="space-y-4">
<div className="flex gap-2">
{SPEED_PRESETS.map((preset) => (
<Button
key={preset.value}
variant={speed === preset.value ? "default" : "outline"}
className="flex-1"
onClick={() => setSpeed(preset.value)}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-1">
<Label>Custom ({speed.toFixed(1)}x)</Label>
<Slider
value={[speed]}
min={0.1}
max={2.0}
step={0.1}
onValueChange={(value) => setSpeed(value[0])}
className="mt-2"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { getDropLineY } from "@/lib/timeline/drop-utils";
import type { TimelineTrack, DropTarget } from "@/types/timeline";
interface DragLineProps {
dropTarget: DropTarget | null;
tracks: TimelineTrack[];
isVisible: boolean;
}
export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) {
if (!isVisible || !dropTarget) return null;
const y = getDropLineY({ dropTarget, tracks });
return (
<div
className="bg-primary pointer-events-none absolute left-0 right-0 z-50 h-0.5"
style={{ top: `${y}px` }}
/>
);
}

View File

@ -8,10 +8,8 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from "../../ui/context-menu";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useCallback } from "react";
import { TimelineTrackContent } from "./timeline-track";
import {
TimelinePlayhead,
@ -19,10 +17,11 @@ import {
} from "./timeline-playhead";
import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapIndicator } from "./snap-indicator";
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/use-element-interaction";
import {
getTrackHeight,
getCumulativeHeightBefore,
@ -30,37 +29,59 @@ import {
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
import { useScrollSync } from "@/hooks/use-scroll-sync";
import { useElementSelection } from "@/hooks/use-element-selection";
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
import { DragLine } from "./drag-line";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
export function Timeline() {
const {
tracks,
getTotalDuration,
clearSelectedElements,
snappingEnabled,
setSelectedElements,
toggleTrackMute,
dragState,
} = useTimelineStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
const editor = useEditor();
const tracks = editor.timeline.sortedTracks;
const currentTime = editor.playback.currentTime;
const duration = editor.timeline.getTotalDuration();
const seek = (time: number) => editor.playback.seek({ time });
const { snappingEnabled } = useTimelineStore();
const { clearSelection, setSelection } = useElementSelection();
const timelineRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
const tracksContainerRef = useRef<HTMLDivElement>(null);
const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
// Timeline zoom functionality
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
containerRef: timelineRef,
isInTimeline,
});
const { dragProps } = useTimelineDragDrop({
const {
dragState,
handleElementMouseDown,
handleElementClick,
lastMouseXRef,
} = useElementInteraction({
zoomLevel,
timelineRef,
tracksContainerRef,
onSnapPointChange: handleSnapPointChange,
});
// Dynamic timeline width calculation based on playhead position and duration
const dynamicTimelineWidth = Math.max(
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
(currentTime + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
@ -69,14 +90,6 @@ export function Timeline() {
timelineRef.current?.clientWidth || 1000,
);
// Scroll synchronization and auto-scroll to playhead
const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
// Timeline playhead ruler handlers
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
currentTime,
duration,
@ -88,8 +101,11 @@ export function Timeline() {
playheadRef,
});
// Selection box functionality
const tracksContainerRef = useRef<HTMLDivElement>(null);
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
zoomLevel,
});
const {
selectionBox,
handleMouseDown: handleSelectionMouseDown,
@ -99,23 +115,13 @@ export function Timeline() {
containerRef: tracksContainerRef,
playheadRef,
onSelectionComplete: (elements) => {
console.log(JSON.stringify({ onSelectionComplete: elements.length }));
setSelectedElements(elements);
setSelection(elements);
},
});
// Calculate snap indicator state
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
const showSnapIndicator =
dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
// Callback to handle snap point changes from TimelineTrackContent
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
const { handleTimelineMouseDown, handleTimelineContentClick } =
useTimelineInteractions({
playheadRef,
@ -126,19 +132,10 @@ export function Timeline() {
duration,
isSelecting,
justFinishedSelecting,
clearSelectedElements,
clearSelectedElements: clearSelection,
seek,
});
// Update timeline duration when tracks change
useEffect(() => {
const totalDuration = getTotalDuration();
setDuration(
Math.max(totalDuration, TIMELINE_CONSTANTS.MIN_DURATION_SECONDS),
);
}, [tracks, setDuration, getTotalDuration]);
// --- Scroll synchronization effect ---
useScrollSync({
rulerScrollRef,
tracksScrollRef,
@ -154,7 +151,10 @@ export function Timeline() {
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
>
<TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} />
<TimelineToolbar
zoomLevel={zoomLevel}
setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
/>
<div
className="relative flex flex-1 flex-col overflow-hidden"
@ -185,17 +185,11 @@ export function Timeline() {
tracksScrollRef={tracksScrollRef}
isVisible={showSnapIndicator}
/>
{/* Timeline Header with Ruler */}
<div className="bg-panel sticky top-0 z-10 flex">
{/* Track Labels Header */}
<div className="bg-panel flex w-28 shrink-0 items-center justify-between border-r px-3 py-2">
{/* Empty space */}
<span className="text-muted-foreground text-sm font-medium opacity-0">
.
</span>
<span className="opacity-0">.</span>
</div>
{/* Timeline Ruler */}
<TimelineRuler
zoomLevel={zoomLevel}
duration={duration}
@ -209,9 +203,7 @@ export function Timeline() {
/>
</div>
{/* Tracks Area */}
<div className="flex flex-1 overflow-hidden">
{/* Track Labels */}
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
@ -232,12 +224,20 @@ export function Timeline() {
{track.muted ? (
<VolumeOff
className="text-destructive h-4 w-4 cursor-pointer"
onClick={() => toggleTrackMute(track.id)}
onClick={() =>
editor.timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
) : (
<Volume2
className="text-muted-foreground h-4 w-4 cursor-pointer"
onClick={() => toggleTrackMute(track.id)}
onClick={() =>
editor.timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
)}
<Eye className="text-muted-foreground h-4 w-4" />
@ -250,13 +250,11 @@ export function Timeline() {
</div>
)}
{/* Timeline Tracks Content */}
<div
className="relative flex-1 overflow-hidden"
onWheel={(e) => {
// Check if this is horizontal scrolling - if so, don't handle it here
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
return; // Let ScrollArea handle horizontal scrolling
return;
}
handleWheel(e);
}}
@ -273,6 +271,11 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
<DragLine
dropTarget={dropTarget}
tracks={tracks}
isVisible={isDragOver}
/>
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
@ -301,22 +304,24 @@ export function Timeline() {
height: `${getTrackHeight({ type: track.type })}px`,
}}
onClick={(e) => {
// If clicking empty area (not on a element), deselect all elements
if (
!(e.target as HTMLElement).closest(
".timeline-element",
)
) {
clearSelectedElements();
clearSelection();
}
}}
>
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
onSnapPointChange={handleSnapPointChange}
dragState={dragState}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
/>
</div>
</ContextMenuTrigger>
@ -324,7 +329,9 @@ export function Timeline() {
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
toggleTrackMute(track.id);
editor.timeline.toggleTrackMute({
trackId: track.id,
});
}}
>
{track.muted ? "Unmute Track" : "Mute Track"}

View File

@ -15,11 +15,14 @@ import {
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform";
import { TimelineElementProps } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/timeline/use-timeline-element-resize";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/use-element-resize";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getTrackElementClasses, getTrackHeight } from "@/lib/timeline";
import {
getTrackColor,
getTrackHeight,
isMutableElement,
} from "@/lib/timeline";
import {
ContextMenu,
ContextMenuContent,
@ -28,6 +31,24 @@ import {
ContextMenuTrigger,
} from "../../ui/context-menu";
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
import {
TimelineElement as TimelineElementType,
TimelineTrack,
} from "@/types/timeline";
import { ElementDragState } from "@/types/timeline";
interface TimelineElementProps {
element: TimelineElementType;
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onElementMouseDown: (
e: React.MouseEvent,
element: TimelineElementType,
) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
}
export function TimelineElement({
element,
@ -36,11 +57,11 @@ export function TimelineElement({
isSelected,
onElementMouseDown,
onElementClick,
dragState,
}: TimelineElementProps) {
const { mediaFiles } = useMediaStore();
const { requestRevealMedia } = useAssetsPanelStore();
const {
dragState,
copySelected,
selectedElements,
deleteSelected,
@ -58,12 +79,11 @@ export function TimelineElement({
: null;
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
useTimelineElementResize({
element,
track,
zoomLevel,
});
const { handleResizeStart } = useTimelineElementResize({
element,
track,
zoomLevel,
});
const {
isMultipleSelected,
@ -215,7 +235,7 @@ export function TimelineElement({
}
};
const isMuted = element.type === "media" && element.muted;
const isMuted = isMutableElement(element) && element.muted;
return (
<ContextMenu>
@ -230,12 +250,9 @@ export function TimelineElement({
}}
data-element-id={element.id}
data-track-id={track.id}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackElementClasses(
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackColor(
{
type: track.type,
},
@ -266,13 +283,25 @@ export function TimelineElement({
<>
<div
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
onMouseDown={(e) =>
handleResizeStart({
e,
elementId: element.id,
side: "left",
})
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
<div
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
onMouseDown={(e) =>
handleResizeStart({
e,
elementId: element.id,
side: "right",
})
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>

View File

@ -1,7 +1,5 @@
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 { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/use-element-selection";
import { toast } from "sonner";
import {
TooltipProvider,
@ -46,97 +44,41 @@ export function TimelineToolbar({
setZoomLevel,
}: {
zoomLevel: number;
setZoomLevel: (zoom: number) => void;
setZoomLevel: ({ zoom }: { zoom: number }) => void;
}) {
const {
tracks,
addTrack,
addElementToTrack,
selectedElements,
clearSelectedElements,
deleteSelected,
splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
snappingEnabled,
toggleSnapping,
rippleEditingEnabled,
toggleRippleEditing,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
const { activeProject } = useProjectStore();
const { toggleBookmark, isBookmarked } = useSceneStore();
const { scenes, currentScene } = useSceneStore();
const editor = useEditor();
const { selectedElements, clearSelection } = useElementSelection();
const handleSplitSelected = () => {
splitSelected(currentTime);
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.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!");
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
editor.timeline.duplicateElements({ elements: selectedElements });
clearSelection();
};
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);
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.currentTime,
retainSide: "left",
});
};
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);
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.currentTime,
retainSide: "right",
});
};
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
@ -150,248 +92,329 @@ export function TimelineToolbar({
TIMELINE_CONSTANTS.ZOOM_MIN,
zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP,
);
setZoomLevel(newZoomLevel);
setZoomLevel({ zoom: newZoomLevel });
};
const currentBookmarked = isBookmarked({ time: currentTime });
const hasNoTracks = editor.timeline.getTracks().length === 0;
return (
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
<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="bg-border mx-1 h-6 w-px" />
{/* 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-muted-foreground px-2 font-mono text-xs">
/
</div>
<div className="text-muted-foreground text-center font-mono text-xs">
{formatTimeCode({ timeInSeconds: duration })}
{formatTimeCode({
timeInSeconds: duration,
format: "HH:MM:SS:FF",
})}
</div>
</div>
{tracks.length === 0 && (
<>
<div className="bg-border mx-1 h-6 w-px" />
<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="bg-border mx-1 h-6 w-px" />
<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" disabled>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Coming soon)</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={() => deleteSelected()}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<div className="bg-border mx-1 h-6 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={() => toggleBookmark({ time: currentTime })}
>
<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-foreground/10 border">
<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="text-primary h-4 w-4" />
) : (
<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>
<ToolbarLeftSection
hasNoTracks={hasNoTracks}
onSplit={handleSplitSelected}
onSplitLeft={handleSplitAndKeepLeft}
onSplitRight={handleSplitAndKeepRight}
onDuplicate={handleDuplicateSelected}
/>
<SceneSelector />
<ToolbarRightSection
zoomLevel={zoomLevel}
onZoomChange={(zoom) => setZoomLevel({ zoom })}
onZoom={handleZoom}
/>
</div>
);
}
function ToolbarLeftSection({
hasNoTracks,
onSplit,
onSplitLeft,
onSplitRight,
onDuplicate,
}: {
hasNoTracks: boolean;
onSplit: () => void;
onSplitLeft: () => void;
onSplitRight: () => void;
onDuplicate: () => void;
}) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
const currentTime = editor.playback.currentTime;
const duration = editor.timeline.getTotalDuration();
const isPlaying = editor.playback.isPlaying;
const activeProject = editor.project.getActive();
const fps = activeProject?.fps ?? DEFAULT_FPS;
const currentBookmarked = editor.scene.isBookmarked({ time: currentTime });
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => editor.playback.toggle()}
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => editor.playback.seek({ time: 0 })}
>
<SkipBack className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
</Tooltip>
<div className="bg-border mx-1 h-6 w-px" />
<div className="flex items-center gap-1">
<Button
variant="text"
size="icon"
onClick={() => handleZoom({ direction: "out" })}
>
<ZoomOut className="h-4 w-4" />
</Button>
<Slider
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => setZoomLevel(values[0])}
min={TIMELINE_CONSTANTS.ZOOM_MIN}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
/>
<Button
variant="text"
size="icon"
onClick={() => handleZoom({ direction: "in" })}
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
<TimeDisplay currentTime={currentTime} duration={duration} fps={fps} />
<div className="bg-border mx-1 h-6 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={onSplit}>
<Scissors className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={onSplitLeft}
>
<ArrowLeftToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={onSplitRight}
>
<ArrowRightToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" disabled type="button">
<SplitSquareHorizontal className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={onDuplicate}
>
<Copy className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() =>
toast.info("Freeze frame functionality coming soon!")
}
>
<Snowflake className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() =>
editor.timeline.deleteElements({ elements: selectedElements })
}
>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<div className="bg-border mx-1 h-6 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => editor.scene.toggleBookmark({ time: currentTime })}
>
<Bookmark
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
function TimeDisplay({
currentTime,
duration,
fps,
}: {
currentTime: number;
duration: number;
fps: number;
}) {
const editor = useEditor();
return (
<div className="flex flex-row items-center justify-center px-2">
<EditableTimecode
time={currentTime}
duration={duration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={(time) => editor.playback.seek({ time })}
className="text-center"
/>
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
<div className="text-muted-foreground text-center font-mono text-xs">
{formatTimeCode({ timeInSeconds: duration })}
{formatTimeCode({
timeInSeconds: duration,
format: "HH:MM:SS:FF",
})}
</div>
</div>
);
}
function SceneSelector() {
const editor = useEditor();
const currentScene = editor.scene.getCurrentScene();
const scenesCount = editor.scene.getScenes().length;
return (
<div>
<SplitButton className="border-foreground/10 border">
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<ScenesView>
<SplitButtonRight
disabled={scenesCount === 1}
onClick={() => {}}
type="button"
>
<LayersIcon className="size-4" />
</SplitButtonRight>
</ScenesView>
</SplitButton>
</div>
);
}
function ToolbarRightSection({
zoomLevel,
onZoomChange,
onZoom,
}: {
zoomLevel: number;
onZoomChange: (zoom: number) => void;
onZoom: (options: { direction: "in" | "out" }) => void;
}) {
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={() => {}}>
<Magnet className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={() => {}}>
<Link className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Enable Ripple Editing</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="bg-border mx-1 h-6 w-px" />
<div className="flex items-center gap-1">
<Button
variant="text"
size="icon"
type="button"
onClick={() => onZoom({ direction: "out" })}
>
<ZoomOut className="size-4" />
</Button>
<Slider
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => onZoomChange(values[0])}
min={TIMELINE_CONSTANTS.ZOOM_MIN}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
/>
<Button
variant="text"
size="icon"
type="button"
onClick={() => onZoom({ direction: "in" })}
>
<ZoomIn className="size-4" />
</Button>
</div>
</div>
);

View File

@ -1,388 +1,68 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
import { useElementSelection } from "@/hooks/use-element-selection";
import { TimelineElement } from "./timeline-element";
import { TimelineTrack } from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
import { useProjectStore } from "@/stores/project-store";
import { snapTimeToFrame } from "@/lib/time-utils";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { ElementDragState } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
track: TimelineTrack;
zoomLevel: number;
dragState: ElementDragState;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
lastMouseXRef: React.RefObject<number>;
onElementMouseDown: (params: {
e: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
onElementClick: (params: {
e: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
}
export function TimelineTrackContent({
track,
zoomLevel,
onSnapPointChange,
dragState,
rulerScrollRef,
tracksScrollRef,
}: {
track: TimelineTrack;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}) {
const {
tracks,
updateElementStartTime,
updateElementStartTimeWithRipple,
selectedElements,
selectElement,
dragState,
startDrag: startDragAction,
updateDragTime,
endDrag: endDragAction,
clearSelectedElements,
rippleEditingEnabled,
} = useTimelineStore();
lastMouseXRef,
onElementMouseDown,
onElementClick,
}: TimelineTrackContentProps) {
const editor = useEditor();
const { isSelected, clearSelection } = useElementSelection();
const { duration } = usePlaybackStore();
const { isDragOver, wouldOverlap, dragProps } = useTimelineDragDrop({
track,
zoomLevel,
onSnapPointChange,
});
const timelineRef = useRef<HTMLDivElement>(null);
const [mouseDownLocation, setMouseDownLocation] = useState<{
x: number;
y: number;
} | null>(null);
const lastMouseXRef = useRef(0);
// Set up mouse event listeners for drag
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
lastMouseXRef.current = e.clientX;
// On first mouse move during drag, ensure the element is selected
if (dragState.elementId && dragState.trackId) {
const isSelected = selectedElements.some(
(c) =>
c.trackId === dragState.trackId &&
c.elementId === dragState.elementId,
);
if (!isSelected) {
// Select this element (replacing other selections) since we're dragging it
selectElement(dragState.trackId, dragState.elementId, false);
}
}
const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
const finalTime = snapTimeToFrame({
time: adjustedTime,
fps: projectFps,
});
updateDragTime(finalTime);
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
// If this track initiated the drag, we should handle the mouse up regardless of where it occurs
const isTrackThatStartedDrag = dragState.trackId === track.id;
const timelineRect = timelineRef.current?.getBoundingClientRect();
if (!timelineRect) {
if (isTrackThatStartedDrag) {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
dragState.elementId,
dragState.currentTime,
);
} else {
updateElementStartTime(
track.id,
dragState.elementId,
dragState.currentTime,
);
}
endDragAction();
// Clear snap point when drag ends
onSnapPointChange?.(null);
}
return;
}
const isMouseOverThisTrack =
e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
const finalTime = dragState.currentTime;
if (isMouseOverThisTrack) {
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(c) => c.id === dragState.elementId,
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalTime + movingElementDuration;
const targetTrack = tracks.find((t) => t.id === track.id);
const hasOverlap = targetTrack?.elements.some((existingElement) => {
if (
dragState.trackId === track.id &&
existingElement.id === dragState.elementId
) {
return false;
}
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === track.id) {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
dragState.elementId,
finalTime,
);
} else {
updateElementStartTime(
track.id,
dragState.elementId,
finalTime,
);
}
} else {
toast.info("Moving elements between tracks is coming soon!");
}
}
}
} else if (isTrackThatStartedDrag) {
// Mouse is not over this track, but this track started the drag
// This means user released over ruler/outside - update position within same track
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(c) => c.id === dragState.elementId,
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalTime + movingElementDuration;
const hasOverlap = track.elements.some((existingElement) => {
if (existingElement.id === dragState.elementId) {
return false;
}
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
dragState.elementId,
finalTime,
);
} else {
updateElementStartTime(track.id, dragState.elementId, finalTime);
}
}
}
}
if (isTrackThatStartedDrag) {
endDragAction();
// Clear snap point when drag ends
onSnapPointChange?.(null);
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
dragState.isDragging,
dragState.clickOffsetTime,
dragState.elementId,
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
track.id,
updateDragTime,
updateElementStartTime,
endDragAction,
selectedElements,
selectElement,
onSnapPointChange,
]);
const duration = editor.timeline.getTotalDuration();
useEdgeAutoScroll({
isActive: dragState.isDragging,
getMouseClientX: () => lastMouseXRef.current,
getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
});
const handleElementMouseDown = (
e: React.MouseEvent,
element: TimelineElementType,
) => {
setMouseDownLocation({ x: e.clientX, y: e.clientY });
// Detect right-click (button 2) and handle selection without starting drag
const isRightClick = e.button === 2;
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
if (isRightClick) {
// Handle right-click selection
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id,
);
// If element is not selected, select it (keep other selections if multi-select)
if (!isSelected) {
selectElement(track.id, element.id, isMultiSelect);
}
// If element is already selected, keep it selected
// Don't start drag action for right-clicks
return;
}
// Handle multi-selection for left-click with modifiers
if (isMultiSelect) {
selectElement(track.id, element.id, true);
}
// Calculate the offset from the left edge of the element to where the user clicked
const elementElement = e.currentTarget as HTMLElement;
const elementRect = elementElement.getBoundingClientRect();
const clickOffsetX = e.clientX - elementRect.left;
const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDragAction(
element.id,
track.id,
e.clientX,
element.startTime,
clickOffsetTime,
);
};
const handleElementClick = (
e: React.MouseEvent,
element: TimelineElementType,
) => {
e.stopPropagation();
// Check if mouse moved significantly
if (mouseDownLocation) {
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
// If it moved more than a few pixels, consider it a drag and not a click.
if (deltaX > 5 || deltaY > 5) {
setMouseDownLocation(null); // Reset for next interaction
return;
}
}
// Skip selection logic for multi-selection (handled in mousedown)
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
// Handle single selection
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id,
);
if (!isSelected) {
// If element is not selected, select it (replacing other selections)
selectElement(track.id, element.id, false);
}
// If element is already selected, keep it selected (do nothing)
};
return (
<div
className="hover:bg-muted/20 h-full w-full"
onClick={(e) => {
// If clicking empty area (not on an element), deselect all elements
if (!(e.target as HTMLElement).closest(".timeline-element")) {
clearSelectedElements();
}
}}
{...dragProps}
>
<div
ref={timelineRef}
className="track-elements-container relative h-full min-w-full"
>
<div className="hover:bg-muted/20 size-full" onClick={clearSelection}>
<div className="track-elements-container relative h-full min-w-full">
{track.elements.length === 0 ? (
<div
className={`text-muted-foreground flex h-full w-full items-center justify-center rounded-sm border-2 border-dashed text-xs transition-colors ${
isDragOver
? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-muted/30"
}`}
>
{isDragOver
? wouldOverlap
? "Cannot drop - would overlap"
: "Drop element here"
: ""}
</div>
<div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
<>
{track.elements.map((element) => {
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id,
);
const isElementSelected = isSelected({
trackId: track.id,
elementId: element.id,
});
return (
<TimelineElement
@ -390,9 +70,14 @@ export function TimelineTrackContent({
element={element}
track={track}
zoomLevel={zoomLevel}
isSelected={isSelected}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
isSelected={isElementSelected}
onElementMouseDown={(e, el) =>
onElementMouseDown({ e, element: el, track })
}
onElementClick={(e, el) =>
onElementClick({ e, element: el, track })
}
dragState={dragState}
/>
);
})}

View File

@ -1,5 +1,5 @@
import { Button } from "./ui/button";
import { GithubIcon } from "./icons";
import { GithubIcon } from "@opencut/ui/icons";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { SOCIAL_LINKS } from "@/constants/site-constants";

View File

@ -18,7 +18,7 @@ export function RenameProjectDialog({
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
onConfirm: (newName: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);

View File

@ -12,12 +12,13 @@ import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePlaybackStore } from "@/stores/playback-store";
import { DragData } from "@/types/timeline";
import { setAssetDragData } from "@/lib/asset-drag";
import type { AssetDragData } from "@/types/assets";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: DragData;
dragData: AssetDragData;
onDragStart?: (e: React.DragEvent) => void;
onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number;
@ -80,14 +81,9 @@ export function DraggableMediaItem({
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setDragImage(emptyImg, 0, 0);
// Set drag data
e.dataTransfer.setData(
"application/x-media-item",
JSON.stringify(dragData)
);
setAssetDragData({ dataTransfer: e.dataTransfer, dragData });
e.dataTransfer.effectAllowed = "copy";
// Set initial position and show custom drag preview
setDragPosition({ x: e.clientX, y: e.clientY });
setIsDragging(true);
@ -103,13 +99,13 @@ export function DraggableMediaItem({
{variant === "card" ? (
<div
ref={dragRef}
className={cn("relative group", containerClassName ?? "w-28 h-28")}
className={cn("group relative", containerClassName ?? "h-28 w-28")}
>
<div
className={cn(
"flex flex-col gap-1 p-1 h-auto w-full relative cursor-default",
"relative flex h-auto w-full cursor-default flex-col gap-1 p-1",
className,
isHighlighted && highlightClassName
isHighlighted && highlightClassName,
)}
>
<AspectRatio
@ -117,7 +113,7 @@ export function DraggableMediaItem({
className={cn(
"bg-panel-accent relative overflow-hidden",
rounded && "rounded-md",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", // Webkit-specific ghost hiding
)}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
@ -133,7 +129,7 @@ export function DraggableMediaItem({
</AspectRatio>
{showLabel && (
<span
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
aria-label={name}
title={name}
>
@ -148,24 +144,24 @@ export function DraggableMediaItem({
<div
ref={dragRef}
className={cn(
"relative group w-full",
isHighlighted && highlightClassName
"group relative w-full",
isHighlighted && highlightClassName,
)}
>
<div
className={cn(
"h-8 flex items-center gap-3 cursor-default w-full px-1",
"flex h-8 w-full cursor-default items-center gap-3 px-1",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
className
className,
)}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
<div className="w-6 h-6 flex-shrink-0 rounded-[0.35rem] overflow-hidden">
<div className="h-6 w-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview}
</div>
<span className="text-sm truncate flex-1 w-full">{name}</span>
<span className="w-full flex-1 truncate text-sm">{name}</span>
</div>
</div>
)}
@ -176,7 +172,7 @@ export function DraggableMediaItem({
typeof document !== "undefined" &&
createPortal(
<div
className="fixed pointer-events-none z-9999"
className="z-9999 pointer-events-none fixed"
style={{
left: dragPosition.x - 40, // Center the preview (half of 80px)
top: dragPosition.y - 40, // Center the preview (half of 80px)
@ -185,9 +181,9 @@ export function DraggableMediaItem({
<div className="w-[80px]">
<AspectRatio
ratio={1}
className="relative rounded-md overflow-hidden shadow-2xl ring-3 ring-primary"
className="ring-3 ring-primary relative overflow-hidden rounded-md shadow-2xl"
>
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:rounded-none [&_img]:object-cover">
{preview}
</div>
{showPlusOnDrag && (
@ -199,7 +195,7 @@ export function DraggableMediaItem({
</AspectRatio>
</div>
</div>,
document.body
document.body,
)}
</>
);
@ -218,8 +214,8 @@ function PlusButton({
<Button
size="icon"
className={cn(
"absolute bottom-2 right-2 size-5 bg-background hover:bg-panel text-foreground",
className
"bg-background hover:bg-panel text-foreground absolute bottom-2 right-2 size-5",
className,
)}
onClick={(e) => {
e.preventDefault();

View File

@ -1,10 +1,7 @@
import { TextElement } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "./timeline-constants";
export const DEFAULT_TEXT_ELEMENT: Omit<
TextElement,
"id"
> = {
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
content: "Default Text",
@ -16,11 +13,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit<
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,

View File

@ -27,12 +27,13 @@ export const TRACK_HEIGHTS: Record<TrackType, number> = {
audio: 50,
} as const;
export const TRACK_GAP = 4;
export const TIMELINE_CONSTANTS = {
ELEMENT_MIN_WIDTH: 80,
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60,
DEFAULT_TEXT_DURATION: 5,
DEFAULT_IMAGE_DURATION: 5,
DEFAULT_ELEMENT_DURATION: 5,
MIN_DURATION_SECONDS: 10,
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // Padding ahead of playhead
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],

View File

@ -4,6 +4,7 @@ import { SceneManager } from "./managers/scene-manager";
import { ProjectManager } from "./managers/project-manager";
import { MediaManager } from "./managers/media-manager";
import { RendererManager } from "./managers/renderer-manager";
import { CommandManager } from "./managers/commands";
import { buildScene } from "@/services/renderer/scene-builder";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import type { ExportOptions } from "@/types/export";
@ -12,6 +13,7 @@ import { DEFAULT_FPS } from "@/constants/editor-constants";
export class EditorCore {
private static instance: EditorCore | null = null;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
public readonly timeline: TimelineManager;
public readonly scene: SceneManager;
@ -20,6 +22,7 @@ export class EditorCore {
public readonly renderer: RendererManager;
private constructor() {
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
this.scene = new SceneManager(this);

View File

@ -0,0 +1,44 @@
import { Command } from "@/lib/commands";
export class CommandManager {
private history: Command[] = [];
private redoStack: Command[] = [];
execute({ command }: { command: Command }): Command {
command.execute();
this.history.push(command);
this.redoStack = [];
return command;
}
undo(): void {
if (this.history.length === 0) return;
const command = this.history.pop();
command?.undo();
if (command) {
this.redoStack.push(command);
}
}
redo(): void {
if (this.redoStack.length === 0) return;
const command = this.redoStack.pop();
command?.redo();
if (command) {
this.history.push(command);
}
}
canUndo(): boolean {
return this.history.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.history = [];
this.redoStack = [];
}
}

View File

@ -1,13 +1,12 @@
import type { EditorCore } from "@/core";
import type { MediaFile } from "@/types/media";
import type { MediaFile } from "@/types/assets";
import { storageService } from "@/lib/storage/storage-service";
import { generateUUID } from "@/lib/utils";
import { videoCache } from "@/lib/video-cache";
import { generateThumbnail } from "@/lib/media-processing-utils";
export class MediaManager {
private mediaFiles: MediaFile[] = [];
private isLoading = false;
public mediaFiles: MediaFile[] = [];
public isLoading = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
@ -88,32 +87,7 @@ export class MediaManager {
try {
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
const updatedMediaItems = await Promise.all(
mediaItems.map(async (item) => {
if (item.type === "video" && item.file) {
try {
const thumbnailUrl = await generateThumbnail({
videoFile: item.file,
timeInSeconds: 1,
});
return {
...item,
thumbnailUrl,
};
} catch (error) {
console.error(
`Failed to regenerate thumbnail for video ${item.id}:`,
error,
);
return item;
}
}
return item;
}),
);
this.mediaFiles = updatedMediaItems;
this.mediaFiles = mediaItems;
this.notify();
} catch (error) {
console.error("Failed to load media items:", error);

View File

@ -2,12 +2,12 @@ import type { EditorCore } from "@/core";
import { DEFAULT_FPS } from "@/constants/editor-constants";
export class PlaybackManager {
private isPlaying = false;
private currentTime = 0;
private volume = 1;
private muted = false;
private previousVolume = 1;
private speed = 1.0;
public isPlaying = false;
public currentTime = 0;
public volume = 1;
public muted = false;
public previousVolume = 1;
public speed = 1.0;
private listeners = new Set<() => void>();
private playbackTimer: number | null = null;
private lastUpdate = 0;

View File

@ -4,14 +4,19 @@ import type { TCanvasSize } from "@/types/editor";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { generateUUID } from "@/lib/utils";
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE, DEFAULT_BLUR_INTENSITY } from "@/constants/editor-constants";
import {
DEFAULT_FPS,
DEFAULT_CANVAS_SIZE,
DEFAULT_BLUR_INTENSITY,
} from "@/constants/editor-constants";
import { buildDefaultScene } from "@/lib/scene-utils";
import { generateThumbnail } from "@/lib/media-processing-utils";
export class ProjectManager {
private activeProject: TProject | null = null;
private savedProjects: TProject[] = [];
private isLoading = true;
private isInitialized = false;
public activeProject: TProject | null = null;
public savedProjects: TProject[] = [];
public isLoading = true;
public isInitialized = false;
private invalidProjectIds = new Set<string>();
private listeners = new Set<() => void>();
@ -257,6 +262,70 @@ export class ProjectManager {
}
}
async updateProjectThumbnail({
thumbnail,
}: {
thumbnail: string;
}): Promise<void> {
if (!this.activeProject) return;
const updatedProject = {
...this.activeProject,
thumbnail,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
this.activeProject = updatedProject;
this.notify();
await this.loadAllProjects();
} catch (error) {
console.error("Failed to update project thumbnail:", error);
}
}
async prepareExit(): Promise<void> {
if (!this.activeProject) return;
try {
const tracks = this.editor.timeline.getTracks();
const mediaFiles = this.editor.media.getMediaFiles();
const firstElement = tracks
.flatMap((track) => track.elements)
.sort((a, b) => a.startTime - b.startTime)[0];
if (
firstElement &&
(firstElement.type === "video" || firstElement.type === "image")
) {
const mediaFile = mediaFiles.find(
(item) => item.id === firstElement.mediaId,
);
if (mediaFile) {
let thumbnailDataUrl: string | undefined;
if (mediaFile.type === "video" && mediaFile.file) {
thumbnailDataUrl = await generateThumbnail({
videoFile: mediaFile.file,
timeInSeconds: 1,
});
} else if (mediaFile.type === "image" && mediaFile.url) {
thumbnailDataUrl = mediaFile.thumbnailUrl || mediaFile.url;
}
if (thumbnailDataUrl && !thumbnailDataUrl.startsWith("blob:")) {
await this.updateProjectThumbnail({ thumbnail: thumbnailDataUrl });
}
}
}
} catch (error) {
console.error("Failed to generate project thumbnail on exit:", error);
}
}
async updateProjectBackground({
backgroundColor,
}: {

View File

@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import type { ExportOptions, ExportResult } from "@/types/export";
import type { TimelineTrack } from "@/types/timeline";
import type { MediaFile } from "@/types/media";
import type { MediaFile } from "@/types/assets";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
@ -23,7 +23,7 @@ interface AudioElement {
}
export class RendererManager {
private renderTree: RootNode | null = null;
public renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
@ -166,14 +166,17 @@ export class RendererManager {
if (track.muted) continue;
for (const element of track.elements) {
if (element.type !== "media") continue;
if (element.type !== "audio") {
continue;
}
const mediaElement = element;
const mediaItem = mediaMap.get(mediaElement.mediaId);
if (!mediaItem || mediaItem.type !== "audio") continue;
const mediaItem = mediaMap.get(element.mediaId);
if (!mediaItem || mediaItem.type !== "audio") {
continue;
}
const visibleDuration =
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
element.duration - element.trimStart - element.trimEnd;
if (visibleDuration <= 0) continue;
try {
@ -184,11 +187,11 @@ export class RendererManager {
audioElements.push({
buffer: audioBuffer,
startTime: mediaElement.startTime,
duration: mediaElement.duration,
trimStart: mediaElement.trimStart,
trimEnd: mediaElement.trimEnd,
muted: mediaElement.muted || track.muted || false,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || track.muted || false,
});
} catch (error) {
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);

View File

@ -21,8 +21,8 @@ import {
} from "@/lib/timeline/bookmark-utils";
export class SceneManager {
private currentScene: TScene | null = null;
private scenes: TScene[] = [];
public currentScene: TScene | null = null;
public scenes: TScene[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}

View File

@ -4,31 +4,30 @@ import type {
CreateTimelineElement,
TimelineTrack,
TextElement,
DragData,
TimelineElement,
} from "@/types/timeline";
import type { MediaFile } from "@/types/media";
import { calculateTotalDuration } from "@/lib/timeline/calculation-utils";
import { calculateTotalDuration } from "@/lib/timeline";
import { storageService } from "@/lib/storage/storage-service";
import {
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
AddElementToTrackCommand,
UpdateElementTrimCommand,
UpdateElementDurationCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
ToggleElementsHiddenCommand,
ToggleElementsMutedCommand,
UpdateTextElementCommand,
SplitElementsCommand,
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
} from "@/lib/commands/timeline";
export class TimelineManager {
private _tracks: TimelineTrack[] = [];
private history: TimelineTrack[][] = [];
private redoStack: TimelineTrack[][] = [];
private clipboard: {
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
} | null = null;
private tracks: TimelineTrack[] = [];
private snappingEnabled = true;
private rippleEditingEnabled = false;
private selectedElements: { trackId: string; elementId: string }[] = [];
private dragState = {
isDragging: false,
elementId: null as string | null,
trackId: null as string | null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
};
public sortedTracks: TimelineTrack[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {
@ -36,110 +35,18 @@ export class TimelineManager {
}
private initializeTracks(): void {
this._tracks = [];
this.tracks = [];
this.sortedTracks = [];
}
getSortedTracks(): TimelineTrack[] {
throw new Error("Not implemented");
}
toggleSnapping(): void {
throw new Error("Not implemented");
}
toggleRippleEditing(): void {
throw new Error("Not implemented");
}
selectElement({
trackId,
elementId,
multi = false,
}: {
trackId: string;
elementId: string;
multi?: boolean;
}): void {
throw new Error("Not implemented");
}
deselectElement({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
throw new Error("Not implemented");
}
clearSelectedElements(): void {
throw new Error("Not implemented");
}
setSelectedElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
throw new Error("Not implemented");
}
setDragState({
dragState,
}: {
dragState: Partial<{
isDragging: boolean;
elementId: string | null;
trackId: string | null;
startMouseX: number;
startElementTime: number;
clickOffsetTime: number;
currentTime: number;
}>;
}): void {
throw new Error("Not implemented");
}
startDrag({
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime,
}: {
elementId: string;
trackId: string;
startMouseX: number;
startElementTime: number;
clickOffsetTime: number;
}): void {
throw new Error("Not implemented");
}
updateDragTime({ currentTime }: { currentTime: number }): void {
throw new Error("Not implemented");
}
endDrag(): void {
throw new Error("Not implemented");
}
addTrack({ type }: { type: TrackType }): string {
throw new Error("Not implemented");
}
insertTrackAt({ type, index }: { type: TrackType; index: number }): string {
throw new Error("Not implemented");
addTrack({ type, index }: { type: TrackType; index?: number }): string {
const command = new AddTrackCommand(type, index);
this.editor.command.execute({ command });
return command.getTrackId();
}
removeTrack({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
}
removeTrackWithRipple({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
const command = new RemoveTrackCommand(trackId);
this.editor.command.execute({ command });
}
addElementToTrack({
@ -149,7 +56,8 @@ export class TimelineManager {
trackId: string;
element: CreateTimelineElement;
}): void {
throw new Error("Not implemented");
const command = new AddElementToTrackCommand(trackId, element);
this.editor.command.execute({ command });
}
updateElementTrim({
@ -165,7 +73,17 @@ export class TimelineManager {
trimEnd: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
const command = new UpdateElementTrimCommand(
trackId,
elementId,
trimStart,
trimEnd,
);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementDuration({
@ -179,97 +97,99 @@ export class TimelineManager {
duration: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
const command = new UpdateElementDurationCommand(
trackId,
elementId,
duration,
);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementStartTime({
trackId,
elementId,
elements,
startTime,
pushHistory = true,
}: {
trackId: string;
elementId: string;
elements: { trackId: string; elementId: string }[];
startTime: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
const command = new UpdateElementStartTimeCommand(elements, startTime);
this.editor.command.execute({ command });
}
toggleTrackMute({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
}
splitAndKeepLeft({
trackId,
elementId,
splitTime,
}: {
trackId: string;
elementId: string;
splitTime: number;
}): void {
throw new Error("Not implemented");
}
splitAndKeepRight({
trackId,
elementId,
splitTime,
}: {
trackId: string;
elementId: string;
splitTime: number;
}): void {
throw new Error("Not implemented");
}
updateElementStartTimeWithRipple({
trackId,
moveElement({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
}: {
trackId: string;
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
}): void {
throw new Error("Not implemented");
const command = new MoveElementCommand(
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
);
this.editor.command.execute({ command });
}
removeElementFromTrackWithRipple({
trackId,
elementId,
pushHistory = true,
toggleTrackMute({ trackId }: { trackId: string }): void {
const command = new ToggleTrackMuteCommand(trackId);
this.editor.command.execute({ command });
}
splitElements({
elements,
splitTime,
retainSide = "both",
}: {
trackId: string;
elementId: string;
pushHistory?: boolean;
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}): void {
throw new Error("Not implemented");
const command = new SplitElementsCommand(elements, splitTime, retainSide);
this.editor.command.execute({ command });
}
getTotalDuration(): number {
return calculateTotalDuration({ tracks: this._tracks });
return calculateTotalDuration({ tracks: this.sortedTracks });
}
getProjectThumbnail({
projectId,
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
return this.sortedTracks.find((track) => track.id === trackId) ?? null;
}
getElementsWithTracks({
elements,
}: {
projectId: string;
}): Promise<string | null> {
throw new Error("Not implemented");
}
elements:
| { trackId: string; elementId: string }[]
| { trackId: string; elementId: string };
}):
| Array<{ track: TimelineTrack; element: TimelineElement }>
| { track: TimelineTrack; element: TimelineElement }
| null {
const normalized = Array.isArray(elements) ? elements : [elements];
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
[];
undo(): void {
throw new Error("Not implemented");
}
for (const { trackId, elementId } of normalized) {
const track = this.getTrackById({ trackId });
const element = track?.elements.find((el) => el.id === elementId);
redo(): void {
throw new Error("Not implemented");
}
if (track && element) {
result.push({ track, element });
}
}
pushHistory(): void {
throw new Error("Not implemented");
return Array.isArray(elements) ? result : (result[0] ?? null);
}
async loadProjectTimeline({
@ -279,7 +199,19 @@ export class TimelineManager {
projectId: string;
sceneId?: string;
}): Promise<void> {
throw new Error("Not implemented");
try {
const tracks = await storageService.loadTimeline({
projectId,
sceneId,
});
if (tracks) {
this.updateTracks(tracks);
}
} catch (error) {
console.error("Failed to load timeline:", error);
throw error;
}
}
async saveProjectTimeline({
@ -289,91 +221,34 @@ export class TimelineManager {
projectId: string;
sceneId?: string;
}): Promise<void> {
throw new Error("Not implemented");
try {
await storageService.saveTimeline({
projectId,
tracks: this.sortedTracks,
sceneId,
});
} catch (error) {
console.error("Failed to save timeline:", error);
throw error;
}
}
clearTimeline(): void {
throw new Error("Not implemented");
}
copySelected(): void {
throw new Error("Not implemented");
this.updateTracks([]);
}
pasteAtTime({ time }: { time: number }): void {
throw new Error("Not implemented");
const command = new PasteCommand(time);
this.editor.command.execute({ command });
}
deleteSelected({
trackId,
elementId,
deleteElements({
elements,
}: {
trackId?: string;
elementId?: string;
elements: { trackId: string; elementId: string }[];
}): void {
throw new Error("Not implemented");
}
splitSelected({
splitTime,
trackId,
elementId,
}: {
splitTime: number;
trackId?: string;
elementId?: string;
}): void {
throw new Error("Not implemented");
}
toggleSelectedHidden({
trackId,
elementId,
}: {
trackId?: string;
elementId?: string;
}): void {
throw new Error("Not implemented");
}
toggleSelectedMuted({
trackId,
elementId,
}: {
trackId?: string;
elementId?: string;
}): void {
throw new Error("Not implemented");
}
duplicateElement({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
throw new Error("Not implemented");
}
revealElementInMedia({ elementId }: { elementId: string }): void {
throw new Error("Not implemented");
}
getContextMenuState({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
hasAudioElements: boolean;
canSplitSelected: boolean;
currentTime: number;
} {
throw new Error("Not implemented");
const command = new DeleteElementsCommand(elements);
this.editor.command.execute({ command });
}
updateTextElement({
@ -395,14 +270,38 @@ export class TimelineManager {
| "fontWeight"
| "fontStyle"
| "textDecoration"
| "x"
| "y"
| "rotation"
| "opacity"
>
>;
}): void {
throw new Error("Not implemented");
const command = new UpdateTextElementCommand(trackId, elementId, updates);
this.editor.command.execute({ command });
}
duplicateElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new DuplicateElementsCommand({ elements });
this.editor.command.execute({ command });
}
toggleElementsHidden({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsHiddenCommand(elements);
this.editor.command.execute({ command });
}
toggleElementsMuted({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsMutedCommand(elements);
this.editor.command.execute({ command });
}
checkElementOverlap({
@ -419,50 +318,8 @@ export class TimelineManager {
throw new Error("Not implemented");
}
findOrCreateTrack({ trackType }: { trackType: TrackType }): string {
throw new Error("Not implemented");
}
addElementAtTime({
item,
currentTime = 0,
}: {
item: MediaFile | TextElement;
currentTime?: number;
}): boolean {
throw new Error("Not implemented");
}
addElementToNewTrack({
item,
}: {
item: MediaFile | TextElement | DragData;
}): boolean {
throw new Error("Not implemented");
}
getTracks(): TimelineTrack[] {
return this.tracks;
}
getTracksWithMain(): TimelineTrack[] {
return this._tracks;
}
getSelectedElements(): { trackId: string; elementId: string }[] {
return this.selectedElements;
}
getDragState(): typeof this.dragState {
return this.dragState;
}
getSnappingEnabled(): boolean {
return this.snappingEnabled;
}
getRippleEditingEnabled(): boolean {
return this.rippleEditingEnabled;
return this.sortedTracks;
}
subscribe(listener: () => void): () => void {
@ -474,9 +331,8 @@ export class TimelineManager {
this.listeners.forEach((fn) => fn());
}
private updateTracks(newTracks: TimelineTrack[]): void {
this._tracks = newTracks;
this.tracks = newTracks; // TODO: Implement proper sorting with main track
updateTracks(newTracks: TimelineTrack[]): void {
this.sortedTracks = newTracks;
this.notify();
}
}

View File

@ -0,0 +1,324 @@
import {
useState,
useCallback,
useEffect,
useRef,
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/use-element-selection";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import type {
ElementDragState,
TimelineElement,
TimelineTrack,
} from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
const DRAG_THRESHOLD_PX = 5;
interface UseElementInteractionProps {
zoomLevel: number;
timelineRef: RefObject<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
};
export function useElementInteraction({
zoomLevel,
timelineRef,
tracksContainerRef,
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const tracks = editor.timeline.sortedTracks;
const {
isSelected,
select,
handleElementClick: handleSelectionClick,
} = useElementSelection();
const [dragState, setDragState] =
useState<ElementDragState>(initialDragState);
const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
const startDrag = useCallback(
({
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime,
}: Omit<ElementDragState, "isDragging" | "currentTime">) => {
setDragState({
isDragging: true,
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime,
currentTime: startElementTime,
});
},
[],
);
const endDrag = useCallback(() => {
setDragState(initialDragState);
}, []);
// Mouse move: update drag time
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
lastMouseXRef.current = e.clientX;
if (dragState.elementId && dragState.trackId) {
const alreadySelected = isSelected({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
if (!alreadySelected) {
select({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
}
}
const rect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseTime = Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
setDragState((prev) => ({ ...prev, currentTime: snappedTime }));
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [
dragState.isDragging,
dragState.clickOffsetTime,
dragState.elementId,
dragState.trackId,
zoomLevel,
isSelected,
select,
editor.project,
timelineRef,
]);
// Mouse up: resolve drop
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = (e: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
endDrag();
onSnapPointChange?.(null);
return;
}
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(el) => el.id === dragState.elementId,
);
if (!movingElement) {
endDrag();
onSnapPointChange?.(null);
return;
}
const elementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
const dropTarget = computeDropTarget({
elementType: movingElement.type,
mouseX,
mouseY,
tracks,
playheadTime: dragState.currentTime,
isExternalDrop: false,
elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
const snappedTime = snapTimeToFrame({ time: dropTarget.xPosition, fps });
if (dropTarget.isNewTrack) {
const newTrackId = editor.timeline.addTrack({
type: sourceTrack!.type,
index: dropTarget.trackIndex,
});
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
});
} else {
const targetTrack = tracks[dropTarget.trackIndex];
if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
});
}
}
endDrag();
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [
dragState.isDragging,
dragState.elementId,
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
endDrag,
onSnapPointChange,
editor.project,
editor.timeline,
tracksContainerRef,
]);
const handleElementMouseDown = useCallback(
({
e,
element,
track,
}: {
e: React.MouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
mouseDownLocationRef.current = { x: e.clientX, y: e.clientY };
const isRightClick = e.button === 2;
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
// right-click: select if not already selected
if (isRightClick) {
const alreadySelected = isSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
isMultiKey: isMultiSelect,
});
}
return;
}
// multi-select: toggle selection
if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
isMultiKey: true,
});
}
// start drag
const elementRect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const clickOffsetX = e.clientX - elementRect.left;
const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDrag({
elementId: element.id,
trackId: track.id,
startMouseX: e.clientX,
startElementTime: element.startTime,
clickOffsetTime,
});
},
[zoomLevel, startDrag, isSelected, handleSelectionClick],
);
const handleElementClick = useCallback(
({
e,
element,
track,
}: {
e: React.MouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
e.stopPropagation();
// was it a drag or a click?
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(e.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(e.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
mouseDownLocationRef.current = null;
return;
}
}
// modifier keys already handled in mousedown
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
// single click: select if not selected
const alreadySelected = isSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
select({ trackId: track.id, elementId: element.id });
}
},
[isSelected, select],
);
return {
dragState,
handleElementMouseDown,
handleElementClick,
lastMouseXRef,
};
}

View File

@ -0,0 +1,261 @@
import { useState, useEffect } from "react";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { EditorCore } from "@/core";
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
import { UpdateElementStartTimeCommand } from "@/lib/commands/timeline/element/update-element-start-time";
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
import type { MediaFile } from "@/types/assets";
export interface ResizeState {
elementId: string;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
initialStartTime: number;
initialDuration: number;
}
interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
}: UseTimelineElementResizeProps) {
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
const [currentDuration, setCurrentDuration] = useState(element.duration);
const editor = EditorCore.getInstance();
const mediaFiles = editor.media.getMediaFiles();
const activeProject = editor.project.getActive();
useEffect(() => {
if (!resizing) return;
const handleDocumentMouseMove = (e: MouseEvent) => {
updateTrimFromMouseMove({ clientX: e.clientX });
};
const handleDocumentMouseUp = () => {
handleResizeEnd();
};
document.addEventListener("mousemove", handleDocumentMouseMove);
document.addEventListener("mouseup", handleDocumentMouseUp);
return () => {
document.removeEventListener("mousemove", handleDocumentMouseMove);
document.removeEventListener("mouseup", handleDocumentMouseUp);
};
}, [resizing]);
const handleResizeStart = ({
e,
elementId,
side,
}: {
e: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => {
e.stopPropagation();
e.preventDefault();
setResizing({
elementId,
side,
startX: e.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
initialStartTime: element.startTime,
initialDuration: element.duration,
});
setCurrentTrimStart(element.trimStart);
setCurrentTrimEnd(element.trimEnd);
setCurrentStartTime(element.startTime);
setCurrentDuration(element.duration);
};
const canExtendElementDuration = () => {
if (element.type === "text") {
return true;
}
if (element.type === "media") {
const mediaFile = mediaFiles.find(
(file: MediaFile) => file.id === element.mediaId,
);
if (!mediaFile) return false;
if (mediaFile.type === "image") {
return true;
}
return false;
}
return false;
};
const updateTrimFromMouseMove = ({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
const projectFps = activeProject?.fps || DEFAULT_FPS;
if (resizing.side === "left") {
const maxAllowed =
resizing.initialDuration - resizing.initialTrimEnd - 0.1;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
} else {
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
} else {
const newTrimStart = 0;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
}
}
} else {
const calculated = resizing.initialTrimEnd - deltaTime;
if (calculated < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(calculated);
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionNeeded,
fps: projectFps,
});
const newTrimEnd = 0;
setCurrentDuration(newDuration);
setCurrentTrimEnd(newTrimEnd);
} else {
setCurrentTrimEnd(0);
}
} else {
const currentEndTime =
resizing.initialStartTime +
resizing.initialDuration -
resizing.initialTrimStart -
resizing.initialTrimEnd;
const desiredEndTime = currentEndTime + deltaTime;
const snappedEndTime = snapTimeToFrame({
time: desiredEndTime,
fps: projectFps,
});
const newTrimEnd = Math.max(
0,
resizing.initialDuration -
resizing.initialTrimStart -
(snappedEndTime - resizing.initialStartTime),
);
const maxTrimEnd =
resizing.initialDuration - resizing.initialTrimStart - 0.1;
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
setCurrentTrimEnd(finalTrimEnd);
}
}
};
const handleResizeEnd = () => {
if (!resizing) return;
const editor = EditorCore.getInstance();
const trimStartChanged = currentTrimStart !== resizing.initialTrimStart;
const trimEndChanged = currentTrimEnd !== resizing.initialTrimEnd;
const startTimeChanged = currentStartTime !== resizing.initialStartTime;
const durationChanged = currentDuration !== resizing.initialDuration;
if (trimStartChanged || trimEndChanged) {
const trimCommand = new UpdateElementTrimCommand(
track.id,
element.id,
currentTrimStart,
currentTrimEnd,
);
editor.command.execute({ command: trimCommand });
}
if (startTimeChanged) {
const startTimeCommand = new UpdateElementStartTimeCommand(
[{ trackId: track.id, elementId: element.id }],
currentStartTime,
);
editor.command.execute({ command: startTimeCommand });
}
if (durationChanged) {
const durationCommand = new UpdateElementDurationCommand(
track.id,
element.id,
currentDuration,
);
editor.command.execute({ command: durationCommand });
}
setResizing(null);
};
return {
resizing,
isResizing: resizing !== null,
handleResizeStart,
currentTrimStart,
currentTrimEnd,
currentStartTime,
currentDuration,
};
}

View File

@ -1,409 +1,403 @@
import { useState, useRef, useCallback } from "react";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { toast } from "sonner";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import {
useTimelineSnapping,
SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
import { buildTextElement } from "@/lib/timeline/element-utils";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getAssetDragData, hasAssetDragData } from "@/lib/asset-drag";
import type { TrackType, DropTarget } from "@/types/timeline";
import type { MediaAssetDragData } from "@/types/assets";
type DragElementType = "video" | "image" | "audio" | "text";
interface UseTimelineDragDropProps {
track?: TimelineTrack;
containerRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
isSnappingEnabled?: boolean;
}
export function useTimelineDragDrop({
track,
containerRef,
zoomLevel,
isSnappingEnabled = true,
}: UseTimelineDragDropProps) {
const editor = useEditor();
const [isDragOver, setIsDragOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
const [dropPositionIndicator, setDropPositionIndicator] = useState<
number | null
>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [dragElementType, setDragElementType] =
useState<DragElementType | null>(null);
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime } = usePlaybackStore();
const {
tracks,
addElementToTrack,
insertTrackAt,
addTrack,
snappingEnabled,
} = useTimelineStore();
const tracks = editor.timeline.sortedTracks;
const currentTime = editor.playback.currentTime;
const mediaFiles = editor.media.mediaFiles;
const activeProject = editor.project.activeProject;
const dragCounterRef = useRef(0);
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
const projectFps = activeProject?.fps ?? DEFAULT_FPS;
return snapTimeToFrame({ time, fps: projectFps });
},
[activeProject?.fps],
);
const { snapElementEdge } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
});
const getDragElementType = useCallback(
({
dataTransfer,
}: {
dataTransfer: DataTransfer;
}): DragElementType | null => {
const dragData = getAssetDragData({ dataTransfer });
if (!dragData) return null;
const getDropSnappedTime = useCallback(
(dropTime: number, elementDuration: number, excludeElementId?: string) => {
const projectFps = activeProject?.fps || DEFAULT_FPS;
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
if (snappingEnabled) {
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true,
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false,
);
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
}
if (dragData.type === "text") return "text";
if (dragData.type === "media") {
return dragData.mediaType as DragElementType;
}
return finalTime;
return null;
},
[
activeProject?.fps,
snappingEnabled,
snapElementEdge,
tracks,
currentTime,
zoomLevel,
],
[],
);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const hasFiles = e.dataTransfer.types.includes("Files");
if (!hasMediaItem && !hasFiles) return;
dragCounterRef.current++;
if (!isDragOver) setIsDragOver(true);
const getElementDuration = useCallback(
({
elementType,
mediaId,
}: {
elementType: DragElementType;
mediaId?: string;
}): number => {
if (elementType === "text") {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
const media = mediaFiles.find((m) => m.id === mediaId);
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
},
[isDragOver],
[mediaFiles],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.types.includes("Files");
if (!hasAsset && !hasFiles) return;
setIsDragOver(true);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
if (!hasMediaItem) return;
if (track) {
const trackContainer =
(e.currentTarget as HTMLElement).closest(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const hasFiles = e.dataTransfer.types.includes("Files");
const isExternal =
hasFiles && !hasAssetDragData({ dataTransfer: e.dataTransfer });
let overlap = false;
try {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (mediaItemData) {
const dragData: DragData = JSON.parse(mediaItemData);
const duration =
dragData.type === "text"
? 5
: mediaFiles.find((m) => m.id === dragData.id)?.duration || 5;
const snappedTime = getDropSnappedTime(dropTime, duration);
const endTime = snappedTime + duration;
let elementType = getDragElementType({ dataTransfer: e.dataTransfer });
overlap = track.elements.some((el) => {
const elEnd =
el.startTime + (el.duration - el.trimStart - el.trimEnd);
return snappedTime < elEnd && endTime > el.startTime;
});
}
} catch (f) {}
setWouldOverlap(overlap);
setDropPositionIndicator(getDropSnappedTime(dropTime, 5));
e.dataTransfer.dropEffect = overlap ? "none" : "copy";
// external drops default to video until determined on drop
if (!elementType && hasFiles) {
elementType = "video";
}
if (!elementType) return;
setDragElementType(elementType);
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
const duration = getElementDuration({
elementType,
mediaId: dragData?.type === "media" ? dragData.id : undefined,
});
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const target = computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
playheadTime: currentTime,
isExternalDrop: isExternal,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
target.xPosition = getSnappedTime({ time: target.xPosition });
setDropTarget(target);
e.dataTransfer.dropEffect = "copy";
},
[track, zoomLevel, mediaFiles, getDropSnappedTime],
[
containerRef,
tracks,
currentTime,
zoomLevel,
getDragElementType,
getElementDuration,
getSnappedTime,
],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0;
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
}
}, []);
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
const { clientX, clientY } = e;
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
) {
setIsDragOver(false);
setDropTarget(null);
setDragElementType(null);
}
}
},
[containerRef],
);
const executeTextDrop = useCallback(
({
target,
dragData,
}: {
target: DropTarget;
dragData: { name?: string; content?: string };
}) => {
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: "text",
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const element = buildTextElement({
raw: {
id: "",
name: dragData.name ?? "",
type: "text",
content: dragData.content ?? "",
},
startTime: target.xPosition,
});
editor.timeline.addElementToTrack({ trackId, element });
},
[editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
({
target,
dragData,
}: {
target: DropTarget;
dragData: MediaAssetDragData;
}) => {
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
if (!mediaItem) return;
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "media";
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: trackType,
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const duration =
mediaItem.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
if (dragData.mediaType === "audio") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "audio",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
volume: 1,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
muted: false,
},
});
} else if (dragData.mediaType === "video") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "video",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "image",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
},
});
}
},
[editor.timeline, mediaFiles, tracks],
);
const executeFileDrop = useCallback(
async ({ files }: { files: File[] }) => {
if (!activeProject) return;
const processedItems = await processMediaFiles({ files });
for (const item of processedItems) {
await editor.media.addMediaFile({
projectId: activeProject.id,
file: item,
});
const added = editor.media.mediaFiles.find(
(m) => m.name === item.name && m.url === item.url,
);
if (added) {
const trackType: TrackType =
added.type === "audio" ? "audio" : "media";
const trackId = editor.timeline.addTrack({
type: trackType,
index: 0,
});
const duration =
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
if (added.type === "audio") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "audio",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
volume: 1,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
muted: false,
},
});
} else if (added.type === "video") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "video",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "image",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
},
});
}
}
}
},
[activeProject, editor.media, editor.timeline, currentTime],
);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
dragCounterRef.current = 0;
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const currentTarget = dropTarget;
setIsDragOver(false);
setDropTarget(null);
setDragElementType(null);
if (!currentTarget) return;
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasMediaItem && !hasFiles) return;
const trackContainer =
(e.currentTarget as HTMLElement).closest(".track-elements-container") ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
if (!trackContainer) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const mouseY = e.clientY - rect.top;
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const projectFps = activeProject?.fps || DEFAULT_FPS;
const snappedTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
let dropPos: "above" | "on" | "below" = "on";
if (track) {
if (mouseY < 20) dropPos = "above";
else if (mouseY > 40) dropPos = "below";
}
if (!hasAsset && !hasFiles) return;
try {
if (hasMediaItem) {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (!mediaItemData) return;
const dragData: DragData = JSON.parse(mediaItemData);
if (hasAsset) {
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
if (!dragData) return;
if (dragData.type === "text") {
let targetTrackId = track?.id;
let targetTrack = track;
if (!track || track.type !== "text" || dropPos !== "on") {
const mainTrack = getMainTrack({ tracks });
let insertIndex = 0;
if (track) {
const currentIdx = tracks.findIndex((t) => t.id === track.id);
insertIndex = dropPos === "above" ? currentIdx : currentIdx + 1;
} else if (mainTrack) {
insertIndex = tracks.findIndex((t) => t.id === mainTrack.id);
}
targetTrackId = insertTrackAt("text", insertIndex);
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
...DEFAULT_TEXT_ELEMENT,
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
startTime: finalStart,
});
executeTextDrop({ target: currentTarget, dragData });
} else {
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
if (!mediaItem) return;
let targetTrackId = track?.id;
const isVideoOrImage =
dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio";
const isCompatible = track
? isVideoOrImage
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: isAudio
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: false
: false;
let targetTrack = track;
if (!track || !isCompatible || dropPos !== "on") {
if (isVideoOrImage) {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
targetTrackId = addTrack("media");
} else if (
mainTrack.elements.length === 0 &&
(!track || dropPos === "on")
) {
targetTrackId = mainTrack.id;
} else {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: 0;
if (track) idx = dropPos === "above" ? idx : idx + 1;
else idx = tracks.findIndex((t) => t.id === mainTrack.id);
targetTrackId = insertTrackAt("media", idx);
}
} else if (isAudio) {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: tracks.length;
if (track) idx = dropPos === "above" ? idx : idx + 1;
targetTrackId = insertTrackAt("audio", idx);
}
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = mediaItem.duration || 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
type: "media",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: finalStart,
trimStart: 0,
trimEnd: 0,
});
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
if (!activeProject) return;
const processedItems = await processMediaFiles({
files: Array.from(e.dataTransfer.files),
});
for (const item of processedItems) {
await addMediaFile(activeProject.id, item);
const added = useMediaStore
.getState()
.mediaFiles.find(
(m) => m.name === item.name && m.url === item.url,
);
if (added) {
const type: TrackType =
added.type === "audio" ? "audio" : "media";
const tid = insertTrackAt(type, 0);
addElementToTrack(tid, {
type: "media",
mediaId: added.id,
name: added.name,
duration: added.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
await executeFileDrop({ files: Array.from(e.dataTransfer.files) });
}
} catch (err) {
console.error(err);
console.error("Failed to process drop:", err);
toast.error("Failed to process drop");
}
},
[
track,
zoomLevel,
activeProject,
tracks,
mediaFiles,
currentTime,
getDropSnappedTime,
addElementToTrack,
insertTrackAt,
addTrack,
addMediaFile,
],
[dropTarget, executeTextDrop, executeMediaDrop, executeFileDrop],
);
return {
isDragOver,
wouldOverlap,
dropPositionIndicator,
dropTarget,
dragElementType,
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,

View File

@ -1,261 +0,0 @@
import { useState, useEffect } from "react";
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useProjectStore } from "@/stores/project-store";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
}: UseTimelineElementResizeProps) {
const [resizing, setResizing] = useState<ResizeState | null>(null);
const { mediaFiles } = useMediaStore();
const {
updateElementStartTime,
updateElementTrim,
updateElementDuration,
pushHistory,
} = useTimelineStore();
// Set up document-level mouse listeners during resize (like proper drag behavior)
useEffect(() => {
if (!resizing) return;
const handleDocumentMouseMove = (e: MouseEvent) => {
updateTrimFromMouseMove({ clientX: e.clientX });
};
const handleDocumentMouseUp = () => {
handleResizeEnd();
};
// Add document-level listeners for proper drag behavior
document.addEventListener("mousemove", handleDocumentMouseMove);
document.addEventListener("mouseup", handleDocumentMouseUp);
return () => {
document.removeEventListener("mousemove", handleDocumentMouseMove);
document.removeEventListener("mouseup", handleDocumentMouseUp);
};
}, [resizing]); // Re-run when resizing state changes
const handleResizeStart = (
e: React.MouseEvent,
elementId: string,
side: "left" | "right",
) => {
e.stopPropagation();
e.preventDefault();
// Push history once at the start of the resize operation
pushHistory();
setResizing({
elementId,
side,
startX: e.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
});
};
const canExtendElementDuration = () => {
// Text elements can always be extended
if (element.type === "text") {
return true;
}
// Media elements - check the media type
if (element.type === "media") {
const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaFile) return false;
// Images can be extended (static content)
if (mediaFile.type === "image") {
return true;
}
// Videos and audio cannot be extended beyond their natural duration
// (no additional content exists)
return false;
}
return false;
};
const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return;
const deltaX = e.clientX - resizing.startX;
// Reasonable sensitivity for resize operations - similar to timeline scale
const deltaTime = deltaX / (50 * zoomLevel);
// Get project FPS for frame snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
if (resizing.side === "left") {
// Left resize - different behavior for media vs text/image elements
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0) {
// Normal trimming within available content
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: element.startTime + trimDelta,
fps: projectFps,
});
updateElementTrim(
track.id,
element.id,
newTrimStart,
resizing.initialTrimEnd,
false,
);
updateElementStartTime(track.id, element.id, newStartTime, false);
} else {
// Trying to extend beyond trimStart = 0
if (canExtendElementDuration()) {
// Text/Image: extend element to the left by moving startTime and increasing duration
const extensionAmount = Math.abs(calculated);
const maxExtension = element.startTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const newStartTime = snapTimeToFrame({
time: element.startTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: element.duration + actualExtension,
fps: projectFps,
});
// Keep trimStart at 0 and extend the element
updateElementTrim(
track.id,
element.id,
0,
resizing.initialTrimEnd,
false,
);
updateElementDuration(track.id, element.id, newDuration, false);
updateElementStartTime(track.id, element.id, newStartTime, false);
} else {
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
const newTrimStart = 0;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: element.startTime + trimDelta,
fps: projectFps,
});
updateElementTrim(
track.id,
element.id,
newTrimStart,
resizing.initialTrimEnd,
false,
);
updateElementStartTime(track.id, element.id, newStartTime, false);
}
}
} else {
// Right resize - can extend duration for supported element types
const calculated = resizing.initialTrimEnd - deltaTime;
if (calculated < 0) {
// We're trying to extend beyond original duration
if (canExtendElementDuration()) {
// Extend the duration instead of reducing trimEnd further
const extensionNeeded = Math.abs(calculated);
const newDuration = snapTimeToFrame({
time: element.duration + extensionNeeded,
fps: projectFps,
});
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
// Update duration first, then trim
updateElementDuration(track.id, element.id, newDuration, false);
updateElementTrim(
track.id,
element.id,
resizing.initialTrimStart,
newTrimEnd,
false,
);
} else {
// Can't extend - just set trimEnd to 0 (maximum possible extension)
updateElementTrim(
track.id,
element.id,
resizing.initialTrimStart,
0,
false,
);
}
} else {
// Normal trimming within original duration
// Calculate the desired end time based on mouse movement
const currentEndTime =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
const desiredEndTime = currentEndTime + deltaTime;
// Snap the desired end time to frame
const snappedEndTime = snapTimeToFrame({
time: desiredEndTime,
fps: projectFps,
});
// Calculate what trimEnd should be to achieve this snapped end time
const newTrimEnd = Math.max(
0,
element.duration -
element.trimStart -
(snappedEndTime - element.startTime),
);
// Ensure we don't trim more than available content (leave at least 0.1s visible)
const maxTrimEnd = element.duration - element.trimStart - 0.1;
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
updateElementTrim(
track.id,
element.id,
element.trimStart,
finalTrimEnd,
false,
);
}
}
};
const handleResizeEnd = () => {
setResizing(null);
};
return {
resizing,
isResizing: resizing !== null,
handleResizeStart,
// Return empty handlers since we use document listeners now
handleResizeMove: () => {}, // Not used anymore
handleResizeEnd: () => {}, // Not used anymore
};
}

View File

@ -0,0 +1,22 @@
import { useEffect, useMemo, useReducer } from "react";
import { EditorCore } from "@/core";
export function useEditor(): EditorCore {
const editor = useMemo(() => EditorCore.getInstance(), []);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
const unsubscribers = [
editor.playback.subscribe(forceUpdate),
editor.timeline.subscribe(forceUpdate),
editor.scene.subscribe(forceUpdate),
editor.project.subscribe(forceUpdate),
editor.media.subscribe(forceUpdate),
editor.renderer.subscribe(forceUpdate),
];
return () => unsubscribers.forEach((unsub) => unsub());
}, [editor]);
return editor;
}

View File

@ -0,0 +1,121 @@
import { useCallback } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
type ElementRef = { trackId: string; elementId: string };
export function useElementSelection() {
const selectedElements = useTimelineStore((s) => s.selectedElements);
const setSelectedElements = useTimelineStore((s) => s.setSelectedElements);
const isSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
),
[selectedElements],
);
const select = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({ elements: [{ trackId, elementId }] });
},
[setSelectedElements],
);
const addToSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
);
if (alreadySelected) return;
setSelectedElements({
elements: [...selectedElements, { trackId, elementId }],
});
},
[selectedElements, setSelectedElements],
);
const removeFromSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({
elements: selectedElements.filter(
(el) => !(el.trackId === trackId && el.elementId === elementId),
),
});
},
[selectedElements, setSelectedElements],
);
const toggleSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
);
if (alreadySelected) {
removeFromSelection({ trackId, elementId });
} else {
addToSelection({ trackId, elementId });
}
},
[selectedElements, addToSelection, removeFromSelection],
);
const clearSelection = useCallback(() => {
setSelectedElements({ elements: [] });
}, [setSelectedElements]);
const setSelection = useCallback(
(elements: ElementRef[]) => {
setSelectedElements({ elements });
},
[setSelectedElements],
);
/**
* Handles click interaction on an element.
* - Regular click: select only this element
* - Multi-key click (Ctrl/Cmd): toggle this element in selection
*/
const handleElementClick = useCallback(
({
trackId,
elementId,
isMultiKey,
}: ElementRef & { isMultiKey: boolean }) => {
if (isMultiKey) {
toggleSelection({ trackId, elementId });
} else {
select({ trackId, elementId });
}
},
[toggleSelection, select],
);
/**
* Ensures element is selected without toggling.
* Used for drag operations where we want to select if not already.
*/
const ensureSelected = useCallback(
({ trackId, elementId }: ElementRef) => {
if (!isSelected({ trackId, elementId })) {
select({ trackId, elementId });
}
},
[isSelected, select],
);
return {
selectedElements,
isSelected,
select,
setSelection,
addToSelection,
removeFromSelection,
toggleSelection,
clearSelection,
handleElementClick,
ensureSelected,
};
}

View File

@ -1,4 +1,5 @@
import { useState, useRef } from "react";
import { hasAssetDragData } from "@/lib/asset-drag";
interface UseFileUploadOptions {
accept?: string;
@ -7,20 +8,23 @@ interface UseFileUploadOptions {
}
function containsFiles(dataTransfer: DataTransfer): boolean {
const isInternalDrag = dataTransfer.types.includes("application/x-media-item");
const hasFiles = dataTransfer.types.includes("Files");
return !isInternalDrag && hasFiles;
return (
!hasAssetDragData({ dataTransfer }) && dataTransfer.types.includes("Files")
);
}
export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUploadOptions = {}) {
export function useFileUpload({
accept,
multiple,
onFilesSelected,
}: UseFileUploadOptions = {}) {
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
function openFilePicker() {
if (!inputRef.current) return;
inputRef.current.accept = accept || "*";
inputRef.current.multiple = multiple || false;
inputRef.current.click();
@ -31,7 +35,7 @@ export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUplo
if (files && files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
if (event.target) {
event.target.value = "";
}
@ -39,24 +43,24 @@ export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUplo
function handleDragEnter(e: React.DragEvent) {
e.preventDefault();
if (!containsFiles(e.dataTransfer)) return;
dragCounterRef.current += 1;
setIsDragOver(true);
}
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
if (!containsFiles(e.dataTransfer)) return;
}
function handleDragLeave(e: React.DragEvent) {
e.preventDefault();
if (!containsFiles(e.dataTransfer)) return;
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
@ -67,11 +71,11 @@ export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUplo
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
if (onFilesSelected && containsFiles(e.dataTransfer)) {
const files = e.dataTransfer.files;
const shouldUseMultiple = multiple ?? false;
if (shouldUseMultiple) {
onFilesSelected(files);
} else if (files.length > 0) {

View File

@ -0,0 +1,30 @@
import type { AssetDragData } from "@/types/assets";
const MIME_TYPE = "application/x-asset-drag";
export function setAssetDragData({
dataTransfer,
dragData,
}: {
dataTransfer: DataTransfer;
dragData: AssetDragData;
}): void {
dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
}
export function getAssetDragData({
dataTransfer,
}: {
dataTransfer: DataTransfer;
}): AssetDragData | null {
const data = dataTransfer.getData(MIME_TYPE);
return data ? (JSON.parse(data) as AssetDragData) : null;
}
export function hasAssetDragData({
dataTransfer,
}: {
dataTransfer: DataTransfer;
}): boolean {
return dataTransfer.types.includes(MIME_TYPE);
}

View File

@ -0,0 +1,11 @@
export abstract class Command {
abstract execute(): void;
undo(): void {
throw new Error("Undo not implemented for this command");
}
redo(): void {
this.execute();
}
}

View File

@ -0,0 +1,2 @@
export { Command } from "./base-command";
export * from "./timeline";

View File

@ -0,0 +1 @@
export { PasteCommand } from "./paste";

View File

@ -0,0 +1,63 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private pastedElementIds: string[] = [];
constructor(private time: number) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.pastedElementIds = [];
const clipboard = (editor.timeline as any).getClipboard?.();
if (!clipboard || clipboard.items.length === 0) {
return;
}
const minStart = Math.min(
...clipboard.items.map((x: any) => x.element.startTime),
);
const updatedTracks = this.savedState.map((track) => {
const elementsToAdd: TimelineElement[] = [];
for (const item of clipboard.items) {
if (item.trackType !== track.type) continue;
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, this.time + relativeOffset);
const newElementId = generateUUID();
this.pastedElementIds.push(newElementId);
const pastedElement: TimelineElement = {
...item.element,
id: newElementId,
startTime,
} as TimelineElement;
elementsToAdd.push(pastedElement);
}
return elementsToAdd.length > 0
? { ...track, elements: [...track.elements, ...elementsToAdd] }
: track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,113 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
TimelineTrack,
TimelineElement,
} from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { validateElementTrackCompatibility } from "@/lib/timeline/track-utils";
import type { MediaFile } from "@/types/assets";
export class AddElementToTrackCommand extends Command {
private elementId: string;
private savedState: TimelineTrack[] | null = null;
constructor(
private trackId: string,
private element: CreateTimelineElement,
) {
super();
this.elementId = generateUUID();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const track = this.savedState.find((t) => t.id === this.trackId);
if (!track) {
console.error("Track not found:", this.trackId);
return;
}
const validation = validateElementTrackCompatibility({
element: this.element,
track,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
if (this.element.type !== "text" && !this.element.mediaId) {
console.error("Media element must have mediaId");
return;
}
if (this.element.type === "text" && !this.element.content) {
console.error("Text element must have content");
return;
}
const totalElementsInTimeline = this.savedState.reduce(
(total, t) => total + t.elements.length,
0,
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement: TimelineElement = {
...this.element,
id: this.elementId,
startTime: this.element.startTime,
trimStart: this.element.trimStart ?? 0,
trimEnd: this.element.trimEnd ?? 0,
} as TimelineElement;
const isVisualMedia =
newElement.type === "video" || newElement.type === "image";
if (isFirstElement && isVisualMedia) {
const mediaFiles = editor.media.getMediaFiles();
const mediaItem = mediaFiles.find(
(item: MediaFile) => item.id === newElement.mediaId,
);
if (mediaItem?.width && mediaItem?.height) {
editor.project.updateCanvasSize({
size: {
width: mediaItem.width,
height: mediaItem.height,
},
});
}
if (mediaItem?.type === "video" && mediaItem?.fps) {
const activeProject = editor.project.getActive();
if (activeProject) {
editor.project.updateProjectFps({ fps: mediaItem.fps });
}
}
}
const updatedTracks = this.savedState.map((t) =>
t.id === this.trackId
? { ...t, elements: [...t.elements, newElement] }
: t,
) as TimelineTrack[];
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getElementId(): string {
return this.elementId;
}
}

View File

@ -0,0 +1,50 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private elements: { trackId: string; elementId: string }[]) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState
.map((track) => {
const hasElementsToDelete = this.elements.some(
(el) => el.trackId === track.id,
);
if (!hasElementsToDelete) {
return track;
}
return {
...track,
elements: track.elements.filter(
(element) =>
!this.elements.some(
(el) => el.trackId === track.id && el.elementId === element.id,
),
),
} as typeof track;
})
.filter(
(track) =>
track.elements.length > 0 || (track.type === "media" && track.isMain),
);
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,71 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { EditorCore } from "@/core";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
}
export class DuplicateElementsCommand extends Command {
private duplicatedIds: string[] = [];
private savedState: TimelineTrack[] | null = null;
private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) {
super();
this.elements = elements;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.duplicatedIds = [];
const updatedTracks = this.savedState.map((track) => {
const elementsToDuplicate = this.elements.filter(
(el) => el.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
return track;
}
const newElements = track.elements.flatMap((element) => {
const shouldDuplicate = elementsToDuplicate.some(
(el) => el.elementId === element.id,
);
if (!shouldDuplicate) {
return [element];
}
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
const newId = generateUUID();
this.duplicatedIds.push(newId);
return [
element,
{
...element,
id: newId,
name: `${element.name} (copy)`,
startTime: element.startTime + effectiveDuration + 0.1,
},
];
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,11 @@
export { AddElementToTrackCommand } from "./add-element";
export { DeleteElementsCommand } from "./delete-elements";
export { DuplicateElementsCommand } from "./duplicate-elements";
export { UpdateElementTrimCommand } from "./update-element-trim";
export { UpdateElementDurationCommand } from "./update-element-duration";
export { UpdateElementStartTimeCommand } from "./update-element-start-time";
export { SplitElementsCommand } from "./split-elements";
export { UpdateTextElementCommand } from "./update-text-element";
export { ToggleElementsHiddenCommand } from "./toggle-elements-hidden";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-element";

View File

@ -0,0 +1,89 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import { validateElementTrackCompatibility } from "@/lib/timeline/track-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private sourceTrackId: string,
private targetTrackId: string,
private elementId: string,
private newStartTime: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find((t) => t.id === this.sourceTrackId);
const element = sourceTrack?.elements.find((el) => el.id === this.elementId);
if (!sourceTrack || !element) {
console.error("Source track or element not found");
return;
}
const targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
if (!targetTrack) {
console.error("Target track not found");
return;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
const movedElement: TimelineElement = {
...element,
startTime: this.newStartTime,
};
const isSameTrack = this.sourceTrackId === this.targetTrackId;
const updatedTracks = this.savedState.map((track) => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.map((el) =>
el.id === this.elementId ? movedElement : el,
),
};
}
if (track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.filter((el) => el.id !== this.elementId),
};
}
if (track.id === this.targetTrackId) {
return {
...track,
elements: [...track.elements, movedElement],
};
}
return track;
}) as TimelineTrack[];
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,116 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { EditorCore } from "@/core";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private newElementIds: string[] = [];
constructor(
private elements: { trackId: string; elementId: string }[],
private splitTime: number,
private retainSide: "both" | "left" | "right" = "both",
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.newElementIds = [];
const updatedTracks = this.savedState.map((track) => {
const elementsToSplit = this.elements.filter(
(el) => el.trackId === track.id,
);
if (elementsToSplit.length === 0) {
return track;
}
return {
...track,
elements: track.elements.flatMap((element) => {
const shouldSplit = elementsToSplit.some(
(el) => el.elementId === element.id,
);
if (!shouldSplit) {
return [element];
}
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (
this.splitTime <= effectiveStart ||
this.splitTime >= effectiveEnd
) {
return [element];
}
const relativeTime = this.splitTime - element.startTime;
const firstDuration = relativeTime;
const secondDuration =
element.duration -
element.trimStart -
element.trimEnd -
relativeTime;
if (this.retainSide === "left") {
return [
{
...element,
trimEnd: element.trimEnd + secondDuration,
name: `${element.name} (left)`,
},
];
}
if (this.retainSide === "right") {
return [
{
...element,
id: generateUUID(),
startTime: this.splitTime,
trimStart: element.trimStart + firstDuration,
name: `${element.name} (right)`,
},
];
}
// "both" - split into two pieces
const secondElementId = generateUUID();
this.newElementIds.push(secondElementId);
return [
{
...element,
trimEnd: element.trimEnd + secondDuration,
name: `${element.name} (left)`,
},
{
...element,
id: secondElementId,
startTime: this.splitTime,
trimStart: element.trimStart + firstDuration,
name: `${element.name} (right)`,
},
];
}),
} as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,47 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { canBeHidden } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
export class ToggleElementsHiddenCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private elements: { trackId: string; elementId: string }[]) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const shouldHide = this.elements.some(({ trackId, elementId }) => {
const track = this.savedState?.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
return element && canBeHidden(element) && !element.hidden;
});
const updatedTracks = this.savedState.map((track) => {
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
({ trackId, elementId }) =>
track.id === trackId && element.id === elementId,
);
return shouldUpdate &&
canBeHidden(element) &&
element.hidden !== shouldHide
? { ...element, hidden: shouldHide }
: element;
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,57 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { isMutableElement } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
export class ToggleElementsMutedCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private elements: { trackId: string; elementId: string }[]) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const mutableElements = this.elements.filter(({ trackId, elementId }) => {
const track = this.savedState?.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
return element && isMutableElement(element);
});
if (mutableElements.length === 0) {
return;
}
const shouldMute = mutableElements.some(({ trackId, elementId }) => {
const track = this.savedState?.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
return element && isMutableElement(element) && !element.muted;
});
const updatedTracks = this.savedState.map((track) => {
const newElements = track.elements.map((element) => {
const shouldUpdate = mutableElements.some(
({ trackId, elementId }) =>
track.id === trackId && element.id === elementId,
);
return shouldUpdate &&
isMutableElement(element) &&
element.muted !== shouldMute
? { ...element, muted: shouldMute }
: element;
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,37 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateElementDurationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private trackId: string,
private elementId: string,
private duration: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId ? { ...el, duration: this.duration } : el,
);
return { ...t, elements: newElements } as typeof t;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private elements: { trackId: string; elementId: string }[],
private startTime: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
const hasElementsToUpdate = this.elements.some(
(el) => el.trackId === track.id,
);
if (!hasElementsToUpdate) {
return track;
}
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
(el) => el.elementId === element.id && el.trackId === track.id,
);
return shouldUpdate
? { ...element, startTime: Math.max(0, this.startTime) }
: element;
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,40 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private trackId: string,
private elementId: string,
private trimStart: number,
private trimEnd: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId
? { ...el, trimStart: this.trimStart, trimEnd: this.trimEnd }
: el,
);
return { ...t, elements: newElements } as typeof t;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,52 @@
import { Command } from "@/lib/commands/base-command";
import type { TextElement, TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateTextElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private trackId: string,
private elementId: string,
private updates: Partial<
Pick<
TextElement,
| "content"
| "fontSize"
| "fontFamily"
| "color"
| "backgroundColor"
| "textAlign"
| "fontWeight"
| "fontStyle"
| "textDecoration"
>
>,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId && el.type === "text"
? { ...el, ...this.updates }
: el,
);
return { ...t, elements: newElements } as typeof t;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,3 @@
export * from "./track";
export * from "./element";
export * from "./clipboard";

View File

@ -0,0 +1,60 @@
import { Command } from "@/lib/commands/base-command";
import type { TrackType, TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { EditorCore } from "@/core";
export class AddTrackCommand extends Command {
private trackId: string;
private savedState: TimelineTrack[] | null = null;
constructor(
private type: TrackType,
private index?: number,
) {
super();
this.trackId = generateUUID();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const trackName =
this.type === "media"
? "Media Track"
: this.type === "text"
? "Text Track"
: this.type === "audio"
? "Audio Track"
: "Track";
const newTrack: TimelineTrack = {
id: this.trackId,
name: trackName,
type: this.type,
elements: [],
muted: false,
};
let updatedTracks: TimelineTrack[];
if (this.index !== undefined) {
updatedTracks = [...(this.savedState || [])];
updatedTracks.splice(this.index, 0, newTrack);
} else {
updatedTracks = [...(this.savedState || []), newTrack];
}
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getTrackId(): string {
return this.trackId;
}
}

View File

@ -0,0 +1,3 @@
export { AddTrackCommand } from "./add-track";
export { RemoveTrackCommand } from "./remove-track";
export { ToggleTrackMuteCommand } from "./toggle-track-mute";

View File

@ -0,0 +1,27 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/types/timeline";
export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private trackId: string) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.filter(
(track) => track.id !== this.trackId,
);
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,29 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class ToggleTrackMuteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private trackId: string) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) =>
t.id === this.trackId ? { ...t, muted: !t.muted } : t,
);
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -1,12 +1,16 @@
import { SceneExporter } from "../services/renderer/scene-exporter";
import { buildScene } from "../services/renderer/scene-builder";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { EditorCore } from "@/core";
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
import { ExportOptions, ExportResult } from "@/types/export";
import { TimelineTrack } from "@/types/timeline";
import { MediaFile } from "@/types/media";
import { TimelineTrack, type AudioElement } from "@/types/timeline";
import { MediaFile } from "@/types/assets";
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
format: "mp4",
@ -14,45 +18,39 @@ export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
includeAudio: true,
};
interface AudioElement {
buffer: AudioBuffer;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
muted: boolean;
}
async function collectAudioElements({
tracks,
mediaFiles,
}: {
tracks: TimelineTrack[];
mediaFiles: MediaFile[];
}): Promise<
Omit<AudioElement, "type" | "mediaId" | "volume" | "id" | "name">[]
> {
const AudioContextConstructor =
window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextConstructor();
async function createTimelineAudioBuffer(
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
duration: number,
sampleRate: number = 44100,
): Promise<AudioBuffer | null> {
// Get Web Audio context
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
// Collect all audio elements from timeline
const audioElements: AudioElement[] = [];
const audioElements: Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name"
>[] = [];
const mediaMap = new Map<string, MediaFile>(mediaFiles.map((m) => [m.id, m]));
for (const track of tracks) {
if (track.muted) continue;
for (const element of track.elements) {
if (element.type !== "media") continue;
if (element.type !== "audio") continue;
const mediaElement = element;
const mediaItem = mediaMap.get(mediaElement.mediaId);
const mediaItem = mediaMap.get(element.mediaId);
if (!mediaItem || mediaItem.type !== "audio") continue;
const visibleDuration =
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
element.duration - element.trimStart - element.trimEnd;
if (visibleDuration <= 0) continue;
try {
// Decode audio file
const arrayBuffer = await mediaItem.file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(
arrayBuffer.slice(0),
@ -60,11 +58,11 @@ async function createTimelineAudioBuffer(
audioElements.push({
buffer: audioBuffer,
startTime: mediaElement.startTime,
duration: mediaElement.duration,
trimStart: mediaElement.trimStart,
trimEnd: mediaElement.trimEnd,
muted: mediaElement.muted || track.muted || false,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || track.muted || false,
});
} catch (error) {
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
@ -72,12 +70,74 @@ async function createTimelineAudioBuffer(
}
}
if (audioElements.length === 0) {
return null; // No audio to mix
}
return audioElements;
}
// Create output buffer
const outputChannels = 2; // Stereo
function mixAudioChannels({
element,
outputBuffer,
outputLength,
sampleRate,
}: {
element: Omit<AudioElement, "type" | "mediaId" | "volume" | "id" | "name">;
outputBuffer: AudioBuffer;
outputLength: number;
sampleRate: number;
}): void {
const {
buffer,
startTime,
trimStart,
trimEnd,
duration: elementDuration,
} = element;
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
const sourceDuration = elementDuration - trimStart - trimEnd;
const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate);
const outputStartSample = Math.floor(startTime * sampleRate);
const resampleRatio = sampleRate / buffer.sampleRate;
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
const outputChannels = 2;
for (let channel = 0; channel < outputChannels; channel++) {
const outputData = outputBuffer.getChannelData(channel);
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
const sourceData = buffer.getChannelData(sourceChannel);
for (let i = 0; i < resampledLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
if (sourceIndex >= sourceData.length) break;
outputData[outputIndex] += sourceData[sourceIndex];
}
}
}
async function createTimelineAudioBuffer({
tracks,
mediaFiles,
duration,
sampleRate = 44100,
}: {
tracks: TimelineTrack[];
mediaFiles: MediaFile[];
duration: number;
sampleRate?: number;
}): Promise<AudioBuffer | null> {
const AudioContextConstructor =
window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextConstructor();
const audioElements = await collectAudioElements({ tracks, mediaFiles });
if (audioElements.length === 0) return null;
const outputChannels = 2;
const outputLength = Math.ceil(duration * sampleRate);
const outputBuffer = audioContext.createBuffer(
outputChannels,
@ -85,102 +145,71 @@ async function createTimelineAudioBuffer(
sampleRate,
);
// Mix all audio elements
for (const element of audioElements) {
if (element.muted) continue;
const {
buffer,
startTime,
trimStart,
trimEnd,
duration: elementDuration,
} = element;
// Calculate timing
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
const sourceDuration = elementDuration - trimStart - trimEnd;
const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate);
const outputStartSample = Math.floor(startTime * sampleRate);
// Resample if needed (simple approach)
const resampleRatio = sampleRate / buffer.sampleRate;
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
// Mix each channel
for (let channel = 0; channel < outputChannels; channel++) {
const outputData = outputBuffer.getChannelData(channel);
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
const sourceData = buffer.getChannelData(sourceChannel);
for (let i = 0; i < resampledLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
// Simple resampling (could be improved with proper interpolation)
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
if (sourceIndex >= sourceData.length) break;
outputData[outputIndex] += sourceData[sourceIndex];
}
}
mixAudioChannels({
element,
outputBuffer,
outputLength,
sampleRate,
});
}
return outputBuffer;
}
export async function exportProject(
options: ExportOptions,
): Promise<ExportResult> {
const { format, quality, fps, includeAudio, onProgress, onCancel } = options;
export async function exportProject({
format,
quality,
fps,
includeAudio,
onProgress,
onCancel,
}: ExportOptions): Promise<ExportResult> {
try {
const timelineStore = useTimelineStore.getState();
const mediaStore = useMediaStore.getState();
const projectStore = useProjectStore.getState();
const { tracks, getTotalDuration } = timelineStore;
const { mediaFiles } = mediaStore;
const { activeProject } = projectStore;
const editor = EditorCore.getInstance();
const activeProject = editor.project.activeProject;
if (!activeProject) {
return { success: false, error: "No active project" };
}
const duration = getTotalDuration();
const duration = editor.timeline.getTotalDuration();
if (duration === 0) {
return { success: false, error: "Project is empty" };
}
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
// Create audio buffer if needed
const tracks = editor.timeline.getTracks();
const mediaFiles = editor.media.getMediaFiles();
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.(0.05); // 5% for audio processing
audioBuffer = await createTimelineAudioBuffer(
onProgress?.(0.05);
audioBuffer = await createTimelineAudioBuffer({
tracks,
mediaFiles,
duration,
);
});
}
// Build the scene using the new node system
const backgroundColor =
activeProject.backgroundType === "blur"
? "transparent"
: activeProject.backgroundColor || "#000000";
const scene = buildScene({
tracks,
mediaFiles,
duration,
canvasSize,
backgroundColor:
activeProject.backgroundType === "blur"
? "transparent"
: activeProject.backgroundColor || "#000000",
backgroundColor,
backgroundType: activeProject.backgroundType,
blurIntensity: activeProject.blurIntensity,
});
// Create the exporter
const exporter = new SceneExporter({
width: canvasSize.width,
height: canvasSize.height,
@ -191,17 +220,15 @@ export async function exportProject(
audioBuffer: audioBuffer || undefined,
});
// Set up progress tracking
exporter.on("progress", (progress) => {
const adjustedProgress = includeAudio ? 0.05 + progress * 0.95 : progress;
onProgress?.(adjustedProgress);
});
// Set up cancellation
let cancelled = false;
let isCancelled = false;
const checkCancel = () => {
if (onCancel?.()) {
cancelled = true;
isCancelled = true;
exporter.cancel();
}
};
@ -212,7 +239,7 @@ export async function exportProject(
const buffer = await exporter.export(scene);
clearInterval(cancelInterval);
if (cancelled) {
if (isCancelled) {
return { success: false, cancelled: true };
}
@ -236,10 +263,18 @@ export async function exportProject(
}
}
export function getExportMimeType(format: "mp4" | "webm"): string {
export function getExportMimeType({
format,
}: {
format: "mp4" | "webm";
}): string {
return format === "webm" ? "video/webm" : "video/mp4";
}
export function getExportFileExtension(format: "mp4" | "webm"): string {
export function getExportFileExtension({
format,
}: {
format: "mp4" | "webm";
}): string {
return `.${format}`;
}

View File

@ -4,7 +4,7 @@ import {
getMediaDuration,
getImageDimensions,
} from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import { MediaFile } from "@/types/assets";
import { getVideoInfo } from "./mediabunny-utils";
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
@ -69,20 +69,8 @@ export async function generateThumbnail({
}
frame.draw(ctx, 0, 0, width, height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(URL.createObjectURL(blob));
} else {
reject(new Error("Failed to create thumbnail blob"));
}
},
"image/jpeg",
0.8,
);
});
const dataUrl = canvas.toDataURL("image/jpeg", 0.8);
return dataUrl;
}
export async function processMediaFiles({

View File

@ -1,5 +1,5 @@
import { TProject } from "@/types/project";
import { MediaFile } from "@/types/media";
import { MediaFile } from "@/types/assets";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import {
@ -180,6 +180,7 @@ class StorageService {
width: mediaItem.width,
height: mediaItem.height,
duration: mediaItem.duration,
thumbnailUrl: mediaItem.thumbnailUrl,
ephemeral: mediaItem.ephemeral,
};
@ -229,6 +230,7 @@ class StorageService {
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
thumbnailUrl: metadata.thumbnailUrl,
ephemeral: metadata.ephemeral,
};
}

View File

@ -19,6 +19,7 @@ export interface MediaFileData {
height?: number;
duration?: number;
ephemeral?: boolean;
thumbnailUrl?: string;
sourceStickerIconName?: string;
// File will be stored separately in OPFS
}

View File

@ -1,22 +0,0 @@
import type { TimelineTrack } from "@/types/timeline";
export function calculateTotalDuration({
tracks,
}: {
tracks: TimelineTrack[];
}): number {
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.elements.reduce((maxEnd, element) => {
const elementEnd =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
return Math.max(maxEnd, elementEnd);
}, 0),
);
return Math.max(...trackEndTimes, 0);
}

View File

@ -0,0 +1,213 @@
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
function getTrackAtY({
mouseY,
tracks,
}: {
mouseY: number;
tracks: TimelineTrack[];
}): { trackIndex: number; relativeY: number } | null {
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
const trackHeight = TRACK_HEIGHTS[tracks[i].type];
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
if (mouseY >= trackTop && mouseY < trackBottom) {
return {
trackIndex: i,
relativeY: mouseY - trackTop,
};
}
cumulativeHeight += trackHeight + TRACK_GAP;
}
return null;
}
function isCompatible({
elementType,
trackType,
}: {
elementType: TimelineElement["type"];
trackType: TimelineTrack["type"];
}): boolean {
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "video" || elementType === "image") {
return trackType === "media";
}
return false;
}
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
return tracks.findIndex((t) => t.type === "media" && t.isMain);
}
function findInsertIndex({
elementType,
tracks,
preferredIndex,
insertAbove,
}: {
elementType: TimelineElement["type"];
tracks: TimelineTrack[];
preferredIndex: number;
insertAbove: boolean;
}): { index: number; position: "above" | "below" } {
const mainTrackIndex = getMainTrackIndex({ tracks });
if (elementType === "audio") {
if (preferredIndex <= mainTrackIndex) {
return { index: mainTrackIndex + 1, position: "below" };
}
return {
index: insertAbove ? preferredIndex : preferredIndex + 1,
position: insertAbove ? "above" : "below",
};
}
if (preferredIndex > mainTrackIndex && mainTrackIndex >= 0) {
return { index: mainTrackIndex, position: "above" };
}
return {
index: insertAbove ? preferredIndex : preferredIndex + 1,
position: insertAbove ? "above" : "below",
};
}
export function computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
playheadTime,
isExternalDrop,
elementDuration,
pixelsPerSecond,
zoomLevel,
}: ComputeDropTargetParams): DropTarget {
const xPosition = isExternalDrop
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
const mainTrackIndex = getMainTrackIndex({ tracks });
if (tracks.length === 0) {
if (elementType === "audio") {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: "below",
xPosition,
};
}
return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
}
const trackAtMouse = getTrackAtY({ mouseY, tracks });
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
if (elementType === "audio") {
return {
trackIndex: tracks.length,
isNewTrack: true,
insertPosition: "below",
xPosition,
};
}
if (isAboveAllTracks) {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: "above",
xPosition,
};
}
return {
trackIndex: Math.max(0, mainTrackIndex),
isNewTrack: true,
insertPosition: "above",
xPosition,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
const trackHeight = TRACK_HEIGHTS[track.type];
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
elementType,
trackType: track.type,
});
const endTime = xPosition + elementDuration;
const hasOverlap = wouldElementOverlap({
elements: track.elements as TimelineElement[],
startTime: xPosition,
endTime,
});
if (isTrackCompatible && !hasOverlap) {
return {
trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition,
};
}
const { index, position } = findInsertIndex({
elementType,
tracks,
preferredIndex: trackIndex,
insertAbove: isInUpperHalf,
});
return {
trackIndex: index,
isNewTrack: true,
insertPosition: position,
xPosition,
};
}
export function getDropLineY({
dropTarget,
tracks,
}: {
dropTarget: DropTarget;
tracks: TimelineTrack[];
}): number {
let y = 0;
for (let i = 0; i < dropTarget.trackIndex && i < tracks.length; i++) {
y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
}
if (!dropTarget.isNewTrack && dropTarget.trackIndex < tracks.length) {
return y;
}
if (
dropTarget.insertPosition === "below" &&
dropTarget.trackIndex <= tracks.length
) {
if (dropTarget.trackIndex > 0 && dropTarget.trackIndex <= tracks.length) {
y += TRACK_HEIGHTS[tracks[dropTarget.trackIndex - 1]?.type ?? "media"];
}
}
return y;
}

View File

@ -1,4 +1,26 @@
import { TimelineElement } from "@/types/timeline";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
CreateTimelineElement,
TextElement,
TimelineElement,
AudioElement,
VideoElement,
ImageElement,
} from "@/types/timeline";
import type { AssetDragData } from "@/types/assets";
export function isMutableElement(
element: TimelineElement,
): element is AudioElement | VideoElement {
return element.type === "audio" || element.type === "video";
}
export function canBeHidden(
element: TimelineElement,
): element is VideoElement | ImageElement | TextElement {
return element.type !== "audio";
}
export function checkElementOverlaps({
elements,
@ -52,3 +74,52 @@ export function resolveElementOverlaps({
return resolvedElements;
}
export function wouldElementOverlap({
elements,
startTime,
endTime,
excludeElementId,
}: {
elements: TimelineElement[];
startTime: number;
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((el) => {
if (excludeElementId && el.id === excludeElementId) return false;
const elEnd = el.startTime + el.duration - el.trimStart - el.trimEnd;
return startTime < elEnd && endTime > el.startTime;
});
}
export function buildTextElement({
raw,
startTime,
}: {
raw: TextElement | AssetDragData;
startTime: number;
}): CreateTimelineElement {
const t = raw as Partial<TextElement>;
return {
type: "text",
name: t.name ?? DEFAULT_TEXT_ELEMENT.name,
content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
fontSize:
typeof t.fontSize === "number"
? t.fontSize
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
};
}

View File

@ -1,2 +1,25 @@
import { TimelineTrack } from "@/types/timeline";
export * from "./track-utils";
export * from "./element-utils";
export function calculateTotalDuration({
tracks,
}: {
tracks: TimelineTrack[];
}): number {
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.elements.reduce((maxEnd, element) => {
const elementEnd =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
return Math.max(maxEnd, elementEnd);
}, 0),
);
return Math.max(...trackEndTimes, 0);
}

View File

@ -1,16 +1,19 @@
import type { TrackType, TimelineTrack } from "@/types/timeline";
import { TRACK_COLORS, TRACK_HEIGHTS } from "@/constants/timeline-constants";
import type {
TrackType,
TimelineTrack,
TimelineElement,
} from "@/types/timeline";
import {
TRACK_COLORS,
TRACK_HEIGHTS,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/lib/utils";
export function getTrackColors({ type }: { type: TrackType }) {
export function getTrackColor({ type }: { type: TrackType }) {
return TRACK_COLORS[type];
}
export function getTrackElementClasses({ type }: { type: TrackType }) {
const colors = getTrackColors({ type });
return `${colors.background} ${colors.border}`;
}
export function getTrackHeight({ type }: { type: TrackType }): number {
return TRACK_HEIGHTS[type];
}
@ -22,11 +25,10 @@ export function getCumulativeHeightBefore({
tracks: Array<{ type: TrackType }>;
trackIndex: number;
}): number {
const GAP = 4;
return tracks
.slice(0, trackIndex)
.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }) + GAP,
(sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
0,
);
}
@ -36,42 +38,20 @@ export function getTotalTracksHeight({
}: {
tracks: Array<{ type: TrackType }>;
}): number {
const GAP = 4;
const tracksHeight = tracks.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }),
0,
);
const gapsHeight = Math.max(0, tracks.length - 1) * GAP;
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
return tracksHeight + gapsHeight;
}
export function sortTracksByOrder({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
return [...tracks].sort((a, b) => {
if (a.type === "text" && b.type !== "text") return -1;
if (b.type === "text" && a.type !== "text") return 1;
if (a.type === "audio" && b.type !== "audio") return 1;
if (b.type === "audio" && a.type !== "audio") return -1;
if (a.type === "media" && b.type === "media") {
if (a.isMain && !b.isMain) return 1;
if (!a.isMain && b.isMain) return -1;
}
return 0;
});
}
export function getMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack | null {
return tracks.find((track) => track.isMain) || null;
return tracks.find((track) => track.type === "media" && track.isMain) ?? null;
}
export function ensureMainTrack({
@ -79,7 +59,9 @@ export function ensureMainTrack({
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
const hasMainTrack = tracks.some((track) => track.isMain);
const hasMainTrack = tracks.some(
(track) => track.type === "media" && track.isMain,
);
if (!hasMainTrack) {
const mainTrack: TimelineTrack = {
@ -100,14 +82,13 @@ export function canElementGoOnTrack({
elementType,
trackType,
}: {
elementType: "text" | "media";
elementType: TimelineElement["type"];
trackType: TrackType;
}): boolean {
if (elementType === "text") {
return trackType === "text";
}
if (elementType === "media") {
return trackType === "media" || trackType === "audio";
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "video" || elementType === "image") {
return trackType === "media";
}
return false;
}
@ -116,7 +97,7 @@ export function validateElementTrackCompatibility({
element,
track,
}: {
element: { type: "text" | "media" };
element: { type: TimelineElement["type"] };
track: { type: TrackType };
}): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack({
@ -125,12 +106,10 @@ export function validateElementTrackCompatibility({
});
if (!isValid) {
const errorMessage =
element.type === "text"
? "Text elements can only be placed on text tracks"
: "Media elements can only be placed on media or audio tracks";
return { isValid: false, errorMessage };
return {
isValid: false,
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
};
}
return { isValid: true };

View File

@ -1,5 +1,5 @@
import { type TimelineTrack } from "@/types/timeline";
import { type MediaFile } from "@/types/media";
import { type MediaFile } from "@/types/assets";
import { RootNode } from "./nodes/root-node";
import { VideoNode } from "./nodes/video-node";
import { ImageNode } from "./nodes/image-node";

View File

@ -1,198 +0,0 @@
import { create } from "zustand";
import { useTimelineStore } from "@/stores/timeline-store";
import { useProjectStore } from "./project-store";
import { DEFAULT_FPS } from "@/constants/editor-constants";
interface TPlaybackState {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
speed: number;
muted: boolean;
previousVolume?: number;
}
interface TPlaybackControls {
play: () => void;
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void;
mute: () => void;
unmute: () => void;
toggleMute: () => void;
}
interface TPlaybackStore extends TPlaybackState, TPlaybackControls {
setDuration: (duration: number) => void;
setCurrentTime: (time: number) => void;
}
let playbackTimer: number | null = null;
const startTimer = (store: () => TPlaybackStore) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates
const updateTime = () => {
const state = store();
if (state.isPlaying && state.currentTime < state.duration) {
const now = performance.now();
const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now;
const newTime = state.currentTime + delta * state.speed;
// Get actual content duration from timeline store
const actualContentDuration = useTimelineStore
.getState()
.getTotalDuration();
// Stop at actual content end, not timeline duration
// It was either this or reducing default min timeline to 1 second
const effectiveDuration =
actualContentDuration > 0 ? actualContentDuration : state.duration;
if (newTime >= effectiveDuration) {
// When content completes, pause just before the end so we can see the last frame
const projectFps = useProjectStore.getState().activeProject?.fps;
if (!projectFps)
console.error(
"Project FPS is not set, assuming " + DEFAULT_FPS + "fps",
);
const frameOffset = 1 / (projectFps ?? DEFAULT_FPS); // Stop 1 frame before end based on project FPS
const stopTime = Math.max(0, effectiveDuration - frameOffset);
state.pause();
state.setCurrentTime(stopTime);
// Notify video elements to sync with end position
window.dispatchEvent(
new CustomEvent("playback-seek", {
detail: { time: stopTime },
}),
);
} else {
state.setCurrentTime(newTime);
// Notify video elements to sync
window.dispatchEvent(
new CustomEvent("playback-update", { detail: { time: newTime } }),
);
}
}
playbackTimer = requestAnimationFrame(updateTime);
};
let lastUpdate = performance.now();
playbackTimer = requestAnimationFrame(updateTime);
};
const stopTimer = () => {
if (playbackTimer) {
cancelAnimationFrame(playbackTimer);
playbackTimer = null;
}
};
export const usePlaybackStore = create<TPlaybackStore>((set, get) => ({
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
muted: false,
previousVolume: 1,
speed: 1.0,
play: () => {
const state = get();
const actualContentDuration = useTimelineStore
.getState()
.getTotalDuration();
const effectiveDuration =
actualContentDuration > 0 ? actualContentDuration : state.duration;
if (effectiveDuration > 0) {
const fps = useProjectStore.getState().activeProject?.fps ?? DEFAULT_FPS;
const frameOffset = 1 / fps;
const endThreshold = Math.max(0, effectiveDuration - frameOffset);
if (state.currentTime >= endThreshold) {
get().seek(0);
}
}
set({ isPlaying: true });
startTimer(get);
},
pause: () => {
set({ isPlaying: false });
stopTimer();
},
toggle: () => {
const { isPlaying } = get();
if (isPlaying) {
get().pause();
} else {
get().play();
}
},
seek: (time: number) => {
const { duration } = get();
const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime });
const event = new CustomEvent("playback-seek", {
detail: { time: clampedTime },
});
window.dispatchEvent(event);
},
setVolume: (volume: number) =>
set((state) => ({
volume: Math.max(0, Math.min(1, volume)),
muted: volume === 0,
previousVolume: volume > 0 ? volume : state.previousVolume,
})),
setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed });
const event = new CustomEvent("playback-speed", {
detail: { speed: newSpeed },
});
window.dispatchEvent(event);
},
setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }),
mute: () => {
const { volume, previousVolume } = get();
set({
muted: true,
previousVolume: volume > 0 ? volume : previousVolume,
volume: 0,
});
},
unmute: () => {
const { previousVolume } = get();
set({ muted: false, volume: previousVolume ?? 1 });
},
toggleMute: () => {
const { muted } = get();
if (muted) {
get().unmute();
} else {
get().mute();
}
},
}));

View File

@ -274,7 +274,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
error instanceof Error
? error.message
: "Failed to add sound to timeline",
{ id: `sound-${sound.id}` }
{ id: `sound-${sound.id}` },
);
return false;
}

View File

@ -14,7 +14,7 @@ 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";
import type { MediaFile } from "@/types/assets";
export type StickerCategory = "all" | "general" | "brands" | "emoji";
@ -191,7 +191,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
url: URL.createObjectURL(file),
width: 200,
height: 200,
duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
ephemeral: false,
};
@ -201,7 +201,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
const added = useMediaStore
.getState()
.mediaFiles.find(
(m) => m.url === mediaItem.url && m.name === mediaItem.name
(m) => m.url === mediaItem.url && m.name === mediaItem.name,
);
if (!added) {
throw new Error("Sticker not in media store");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
export type MediaType = "image" | "video" | "audio";
export interface MediaFile {
id: string;
name: string;
type: MediaType;
file: File;
url?: string;
thumbnailUrl?: string;
duration?: number;
width?: number;
height?: number;
fps?: number;
// only used in timeline, never shown or saved in media library
ephemeral?: boolean;
}
interface BaseAssetDragData {
id: string;
name: string;
}
export interface MediaAssetDragData extends BaseAssetDragData {
type: "media";
mediaType: MediaType;
}
export interface TextAssetDragData extends BaseAssetDragData {
type: "text";
content: string;
}
export type AssetDragData = MediaAssetDragData | TextAssetDragData;

View File

@ -3,18 +3,4 @@ export interface TCanvasSize {
height: number;
}
export interface TTextElementDragState {
isDragging: boolean;
elementId: string | null;
trackId: string | null;
startX: number;
startY: number;
initialElementX: number;
initialElementY: number;
currentX: number;
currentY: number;
elementWidth: number;
elementHeight: number;
}
export type TPlatformLayout = "tiktok";

View File

@ -1,17 +0,0 @@
export type MediaType = "image" | "video" | "audio";
// What's stored in media library
export interface MediaFile {
id: string;
name: string;
type: MediaType;
file: File;
url?: string; // Object URL for preview
thumbnailUrl?: string; // For video thumbnails
duration?: number; // For video/audio duration
width?: number; // For video/image width
height?: number; // For video/image height
fps?: number; // For video frame rate
// Ephemeral items are used by timeline directly and should not appear in the media library or be persisted
ephemeral?: boolean;
}

View File

@ -1,8 +1,29 @@
import { MediaType } from "@/types/media";
export type TrackType = "media" | "text" | "audio";
// Base element properties
interface BaseTrack {
id: string;
name: string;
muted?: boolean;
}
export interface MediaTrack extends BaseTrack {
type: "media";
elements: (VideoElement | ImageElement)[];
isMain?: boolean;
}
export interface TextTrack extends BaseTrack {
type: "text";
elements: TextElement[];
}
export interface AudioTrack extends BaseTrack {
type: "audio";
elements: AudioElement[];
}
export type TimelineTrack = MediaTrack | TextTrack | AudioTrack;
interface BaseTimelineElement {
id: string;
name: string;
@ -10,17 +31,29 @@ interface BaseTimelineElement {
startTime: number;
trimStart: number;
trimEnd: number;
}
export interface AudioElement extends BaseTimelineElement {
type: "audio";
mediaId: string;
volume: number;
muted?: boolean;
buffer: AudioBuffer;
}
export interface VideoElement extends BaseTimelineElement {
type: "video";
mediaId: string;
muted?: boolean;
hidden?: boolean;
}
// Media element that references MediaStore
export interface MediaElement extends BaseTimelineElement {
type: "media";
export interface ImageElement extends BaseTimelineElement {
type: "image";
mediaId: string;
muted?: boolean;
hidden?: boolean;
}
// Text element with embedded text data
export interface TextElement extends BaseTimelineElement {
type: "text";
content: string;
@ -32,58 +65,52 @@ export interface TextElement extends BaseTimelineElement {
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";
textDecoration: "none" | "underline" | "line-through";
x: number; // Position relative to canvas center
y: number; // Position relative to canvas center
rotation: number; // in degrees
opacity: number; // 0-1
hidden?: boolean;
}
// Typed timeline elements
export type TimelineElement = MediaElement | TextElement;
export type TimelineElement =
| AudioElement
| VideoElement
| ImageElement
| TextElement;
// Creation types (without id, for addElementToTrack)
export type CreateMediaElement = Omit<MediaElement, "id">;
export type CreateAudioElement = Omit<AudioElement, "id">;
export type CreateVideoElement = Omit<VideoElement, "id">;
export type CreateImageElement = Omit<ImageElement, "id">;
export type CreateTextElement = Omit<TextElement, "id">;
export type CreateTimelineElement = CreateMediaElement | CreateTextElement;
export type CreateTimelineElement =
| CreateAudioElement
| CreateVideoElement
| CreateImageElement
| CreateTextElement;
export interface TimelineElementProps {
element: TimelineElement;
track: TimelineTrack;
// ---- Drag State ----
export interface ElementDragState {
isDragging: boolean;
elementId: string | null;
trackId: string | null;
startMouseX: number;
startElementTime: number;
clickOffsetTime: number;
currentTime: number;
}
export interface DropTarget {
trackIndex: number;
isNewTrack: boolean;
insertPosition: "above" | "below" | null;
xPosition: number;
}
export interface ComputeDropTargetParams {
elementType: TimelineElement["type"];
mouseX: number;
mouseY: number;
tracks: TimelineTrack[];
playheadTime: number;
isExternalDrop: boolean;
elementDuration: number;
pixelsPerSecond: number;
zoomLevel: number;
isSelected: boolean;
onElementMouseDown: (e: React.MouseEvent, element: TimelineElement) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElement) => void;
}
export interface ResizeState {
elementId: string;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
}
// Drag data types for type-safe drag and drop
export interface MediaItemDragData {
id: string;
type: MediaType;
name: string;
}
export interface TextItemDragData {
id: string;
type: "text";
name: string;
content: string;
}
export type DragData = MediaItemDragData | TextItemDragData;
export interface TimelineTrack {
id: string;
name: string;
type: TrackType;
elements: TimelineElement[];
muted?: boolean;
isMain?: boolean;
}

View File

@ -30,6 +30,6 @@
"next-env.d.ts",
".next/types/**/*.ts",
"src/types/**/*.d.ts"
, "../../use-frame-cache.ts" ],
, "../../use-frame-cache.ts", "src/stores/timeline-store.ts" ],
"exclude": ["node_modules"]
}

View File

@ -18,7 +18,6 @@
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@opencut/env": "workspace:*",
"@opencut/hooks": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-separator": "^1.1.7",
"@upstash/ratelimit": "^2.0.6",
@ -92,7 +91,6 @@
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@opencut/env": "workspace:*",
"@opencut/hooks": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-separator": "^1.1.7",
"@upstash/ratelimit": "^2.0.6",
@ -177,7 +175,6 @@
},
},
"packages/hooks": {
"name": "@opencut/hooks",
"version": "0.0.0",
"dependencies": {
"@types/react": "^19.2.7",
@ -394,8 +391,6 @@
"@opencut/env": ["@opencut/env@workspace:packages/env"],
"@opencut/hooks": ["@opencut/hooks@workspace:packages/hooks"],
"@opencut/tools": ["@opencut/tools@workspace:apps/tools"],
"@opencut/ui": ["@opencut/ui@workspace:packages/ui"],

187
chat_summary.md Normal file
View File

@ -0,0 +1,187 @@
## Detailed Chat Summary
### Phase 1: Audit & Analysis
Started with a 1800-line Zustand timeline store doing EVERYTHING (core logic + UI state + history). Performed comprehensive audit identifying:
- ~40 methods mixing core logic with UI orchestration
- Inconsistent parameter patterns (some destructured, some not)
- Redundant methods (`updateElementStartTime` vs `updateElementStartTimeWithRipple`)
- Three separate split operations that should be unified
- Delete operations conflating UI state with core logic
- Methods referencing `selectedElements` (UI state) that shouldn't exist in core
### Phase 2: Architecture Decision
Decided on Command Pattern + separation of concerns:
- **Core Logic** → TimelineManager + Commands
- **UI State** → Zustand store
- **Ephemeral Interaction** → Component state (dragState)
- **History Tracking** → HistoryManager
### Phase 3: HistoryManager Creation
Created generic `HistoryManager<T>`:
- `execute(command)` returns the command (enables getting return values)
- Maintains `history` and `redoStack` arrays of Command objects
- `undo()` pops from history, calls `command.undo()`
- `redo()` pops from redoStack, calls `command.redo()`
- Methods: `canUndo()`, `canRedo()`, `clear()`
### Phase 4: Command Architecture
Created base `Command` abstract class in `src/lib/commands/base-command.ts`:
- Abstract `execute()` method (must implement)
- `undo()` throws by default (implement if reversible)
- `redo()` defaults to calling `execute()` again
Built 15 command classes organized in folders:
- **Track operations** (3): `AddTrackCommand`, `RemoveTrackCommand`, `ToggleTrackMuteCommand`
- **Element CRUD** (5): `AddElementToTrackCommand`, `DeleteElementsCommand`, `DuplicateElementsCommand`, `UpdateElementTrimCommand`, `UpdateElementDurationCommand`
- **Element properties** (4): `UpdateTextElementCommand`, `ToggleElementsHiddenCommand`, `ToggleElementsMutedCommand`, `SplitElementsCommand`
- **Unified position** (1): `UpdateElementStartTimeCommand` (replaced 3 old methods)
- **Clipboard** (1): `PasteCommand`
Each command:
- Takes minimal parameters in constructor
- Saves state before execution for undo
- Implements `execute()` and `undo()`
- Has getters for return values (e.g., `getTrackId()`)
- Uses `EditorCore.getInstance()` to access timeline manager
### Phase 5: AddTrackCommand Details
Updated `AddTrackCommand` to support optional `index` parameter (unified `addTrack` and `insertTrackAt`):
- Constructor: `new AddTrackCommand(type, index?)`
- Splices into array at index if provided, appends if not
- Returns trackId via `getTrackId()` getter
- Saves full state for undo
### Phase 6: TimelineManager Refactor - Part 1
Removed ALL UI state fields:
- ❌ `clipboard` → UI state
- ❌ `snappingEnabled` → UI preference
- ❌ `rippleEditingEnabled` → UI preference
- ❌ `selectedElements` → UI selection
- ❌ `dragState` → ephemeral interaction
- ❌ `DragData` import (only used for UI state)
### Phase 7: TimelineManager Refactor - Part 2
Updated method implementations to use commands:
- `addTrack()``new AddTrackCommand(type, index).execute()`
- `removeTrack()``new RemoveTrackCommand(trackId).execute()`
- `updateElementTrim()` → creates command, conditionally pushes history
- `updateElementDuration()` → creates command, conditionally pushes history
- `updateElementStartTime()` → simplified (always through history)
- `deleteElements()` → simplified (always through history)
- `splitElements()``new SplitElementsCommand(elements, splitTime, retainSide).execute()`
- `duplicateElements()`, `toggleElementsHidden()`, `toggleElementsMuted()` → via commands
### Phase 8: TimelineManager Cleanup
Removed methods that referenced UI state:
- ❌ `getSortedTracks()` (stub, redundant)
- ❌ `toggleSnapping()` (UI preference)
- ❌ `toggleRippleEditing()` (UI preference)
- ❌ `selectElement()`, `deselectElement()`, etc. (UI selection)
- ❌ `setDragState()`, `startDrag()`, `updateDragTime()`, `endDrag()` (UI interaction)
- ❌ `copySelected()` (orchestrates UI state + core logic)
- ❌ `deleteSelectedElements()` (uses UI state)
- ❌ `splitSelected()` (uses UI state)
- ❌ `toggleSelectedHidden()`, `toggleSelectedMuted()` (use UI state)
- ❌ `revealElementInMedia()` (UI navigation)
- ❌ `getContextMenuState()` (UI query)
- ❌ `addElementAtTime()`, `addElementToNewTrack()` (convenience wrappers)
**Result:** TimelineManager reduced from 1800 → 315 lines, now pure core logic only
### Phase 9: Timeline Store Refactor
Completely rewrote store (1800 → 104 lines) to contain ONLY UI state:
```typescript
interface TimelineStore {
// Selection
selectedElements: { trackId: string; elementId: string }[]
selectElement()
deselectElement()
clearSelectedElements()
setSelectedElements()
// Preferences
snappingEnabled: boolean
toggleSnapping()
rippleEditingEnabled: boolean
toggleRippleEditing()
// Clipboard
clipboard: { items: Array<...> } | null
setClipboard()
}
```
Removed all methods that:
- Operated on `_tracks`
- Managed history
- Used commands
- Implemented core timeline logic
### Phase 10: Identified dragState Problem
Analyzed `dragState` usage across three files:
- **timeline-track.tsx** (producer): Sets/updates drag state on mouse events
- **timeline-element.tsx** (consumer): Reads `dragState.elementId` and `dragState.currentTime` for visual feedback during drag
- **index.tsx** (consumer): Reads `dragState.isDragging` to show snap indicator
**Problem:** In global Zustand store (wrong scope)
- Only used during active drag operations
- Ephemeral state (cleared after drag ends)
- Causes unnecessary re-renders across entire app
- Only needs to exist in Timeline component tree
**Solution:** Move to Timeline component state:
- Keep `dragState` as local state in `Timeline` component
- Pass down via props to `TimelineTrack` and `TimelineElement`
- Timeline component manages `handleMouseDown`, `handleMouseMove`, `handleMouseUp` in TimelineTrack
- This keeps scope correct and re-renders localized
### Phase 11: Architectural Alignment
Confirmed final architecture:
- **Editor Core** (singleton):
- `history: HistoryManager` (manages undo/redo)
- `timeline: TimelineManager` (pure data + commands)
- Other managers (project, media, scene, playback)
- **Timeline Component** (React):
- `dragState` (local state via useState)
- Props down to children
- **Timeline Store** (Zustand):
- Selection state + methods
- Preferences (snapping, ripple editing)
- Clipboard state
### Phase 12: Command Pattern Benefits Identified
1. **Self-contained operations** - Each command knows how to undo itself
2. **Discoverable API** - All timeline actions are in `/lib/commands/timeline/`
3. **Type-safe** - Commands are strongly typed
4. **Testable** - Each command can be unit tested independently
5. **Scalable** - Adding new operations = creating new command class
6. **Clean manager** - TimelineManager becomes orchestrator, not logic holder
7. **Reusable** - Commands can be executed from anywhere (UI, scripts, etc.)
### Summary of Changes Made
**Files Created:**
- `src/lib/commands/base-command.ts` (12 lines)
- `src/lib/commands/index.ts` (3 lines)
- `src/lib/commands/timeline/index.ts` (4 lines)
- `src/lib/commands/timeline/track/` (3 commands)
- `src/lib/commands/timeline/element/` (9 commands)
- `src/lib/commands/timeline/clipboard/` (1 command)
**Files Modified:**
- `src/core/managers/history-manager.ts` - Refactored to work with Commands, now returns command
- `src/core/managers/timeline-manager.ts` - Removed UI state, implemented all methods as command-driven
- `src/stores/timeline-store.ts` - From 1800 → 104 lines, now UI state only
**Files Deleted:**
- Old history logic removed (consolidated into HistoryManager)
- Removed unused imports from components (cascading changes needed)
### Remaining Work
1. Move `dragState` from store to Timeline component
2. Update all component imports and method calls
3. Implement remaining stub methods (`checkElementOverlap`, `findOrCreateTrack`, etc.)
4. Wire up UI to call new manager methods instead of store methods

BIN
image.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 900 KiB

View File

@ -1,6 +1,6 @@
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { useTimelineStore } from "./timeline-store";
import { useTimelineStore } from "../apps/web/src/stores/timeline-store";
import { generateUUID } from "@/lib/utils";
import { MediaType, MediaFile } from "@/types/media";
import { videoCache } from "@/lib/video-cache";
@ -12,7 +12,7 @@ interface MediaStore {
// Actions
addMediaFile: (
projectId: string,
file: Omit<MediaFile, "id">
file: Omit<MediaFile, "id">,
) => Promise<void>;
removeMediaFile: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>;
@ -39,7 +39,7 @@ export const getFileType = (file: File): MediaType | null => {
// Helper function to get image dimensions
export const getImageDimensions = (
file: File
file: File,
): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new window.Image();
@ -60,56 +60,11 @@ export const getImageDimensions = (
});
};
export const generateVideoThumbnail = (
file: File
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
return new Promise((resolve, reject) => {
const video = document.createElement("video") as HTMLVideoElement;
const canvas = document.createElement("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
video.addEventListener("loadedmetadata", () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Seek to 1 second or 10% of duration, whichever is smaller
video.currentTime = Math.min(1, video.duration * 0.1);
});
video.addEventListener("seeked", () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
const width = video.videoWidth;
const height = video.videoHeight;
resolve({ thumbnailUrl, width, height });
// Cleanup
video.remove();
canvas.remove();
});
video.addEventListener("error", () => {
reject(new Error("Could not load video"));
video.remove();
canvas.remove();
});
video.src = URL.createObjectURL(file);
video.load();
});
};
// Helper function to get media duration
export const getMediaDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const element = document.createElement(
file.type.startsWith("video/") ? "video" : "audio"
file.type.startsWith("video/") ? "video" : "audio",
) as HTMLVideoElement;
element.addEventListener("loadedmetadata", () => {
@ -213,33 +168,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
try {
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
// Regenerate thumbnails for video items
const updatedMediaItems = await Promise.all(
mediaItems.map(async (item) => {
if (item.type === "video" && item.file) {
try {
const { thumbnailUrl, width, height } =
await generateVideoThumbnail(item.file);
return {
...item,
thumbnailUrl,
width: width || item.width,
height: height || item.height,
};
} catch (error) {
console.error(
`Failed to regenerate thumbnail for video ${item.id}:`,
error
);
return item;
}
}
return item;
})
);
set({ mediaFiles: updatedMediaItems });
set({ mediaFiles: mediaItems });
} catch (error) {
console.error("Failed to load media items:", error);
} finally {
@ -267,7 +196,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);

View File

@ -3,11 +3,15 @@ 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 { useTimelineStore } from "../apps/web/src/stores/timeline-store";
import { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils";
import { TCanvasSize } from "@/types/editor";
import { DEFAULT_BLUR_INTENSITY, DEFAULT_CANVAS_SIZE, DEFAULT_FPS } from "@/constants/editor-constants";
import {
DEFAULT_BLUR_INTENSITY,
DEFAULT_CANVAS_SIZE,
DEFAULT_FPS,
} from "@/constants/editor-constants";
export function createMainScene(): TScene {
return {

View File

@ -1,7 +1,7 @@
import { create } from "zustand";
import { TScene } from "@/types/project";
import { useProjectStore } from "./project-store";
import { useTimelineStore } from "./timeline-store";
import { useTimelineStore } from "../apps/web/src/stores/timeline-store";
import { storageService } from "@/lib/storage/storage-service";
import {
getActiveScene,

View File

@ -1,19 +0,0 @@
{
"name": "@opencut/hooks",
"version": "0.0.0",
"description": "Hooks package for OpenCut",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./use-file-upload": "./src/use-file-upload.ts",
"./use-file-paste": "./src/use-file-paste.ts"
},
"dependencies": {
"@types/react": "^19.2.7",
"react": "^19.2.0"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}

View File

@ -1,2 +0,0 @@
export * from "./use-file-upload";
export * from "./use-file-paste";

View File

@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

118
plan.md Normal file
View File

@ -0,0 +1,118 @@
## Complete Refactor Plan
### ✅ COMPLETED
1. **Created Command Pattern Architecture**
- Base `Command` class with `execute()`, `undo()`, `redo()`
- 15 timeline commands in `src/lib/commands/timeline/`
- All phases implemented (track ops, element ops, toggles, split, paste, etc.)
2. **Created HistoryManager**
- Generic `HistoryManager<T>` for undo/redo tracking
- Returns command after execution (for getting return values like IDs)
- Completely decoupled from timeline logic
3. **Refactored TimelineManager**
- Removed all UI state (`snappingEnabled`, `rippleEditingEnabled`, `selectedElements`, `dragState`, `clipboard`)
- Removed UI-only methods
- Now purely command-driven core logic
- All operations go through commands + history
- Only 315 lines (was 1800+)
- Focus: data management, persistence, queries
4. **Refactored Timeline Store**
- Now ONLY UI state (104 lines)
- `selectedElements` + selection methods
- `snappingEnabled` + toggle
- `rippleEditingEnabled` + toggle
- `clipboard` + setter
- Removed all core logic
### 🔲 TODO - HIGH PRIORITY
1. **Move dragState from Store to Timeline Component**
- Currently in Zustand (wrong place)
- Move to `Timeline` component local state
- Pass down via props to `TimelineTrack` and `TimelineElement`
- Remove from timeline-store.ts
- Eliminates unnecessary global state
2. **Update All Component Imports**
- Remove `dragState`, `startDrag`, `updateDragTime`, `endDrag` from all components reading from store
- Update to receive via props instead
- Components affected:
- `timeline-track.tsx` (producer)
- `timeline-element.tsx` (consumer)
- `index.tsx` (consumer - snap indicator)
3. **Update Store Action Calls**
- Components calling store methods now need to call `editor.timeline.*` instead
- Examples:
- `updateElementStartTime()``editor.timeline.updateElementStartTime()`
- `deleteSelected()``editor.timeline.deleteElements()`
- `copySelected()` stays in store (UI orchestration)
- `pasteAtTime()``editor.timeline.pasteAtTime()` after store manages clipboard
4. **Create useDragState Hook (Optional Later)**
- Extract drag logic into custom hook
- Would contain: drag state, handlers, listeners
- Keep in Timeline for now (simpler), extract later if needed
### 🔲 TODO - MEDIUM PRIORITY
5. **Implement Remaining TimelineManager Methods**
- `checkElementOverlap()` — utility logic
- `findOrCreateTrack()` — utility
- `loadProjectTimeline()` — persistence
- `saveProjectTimeline()` — persistence
- `clearTimeline()` — core operation
- `getSortedTracks()` — already in manager but not implemented
6. **Wire Up Commands to UI**
- Update all action calls to create commands + execute
- E.g., when user clicks "add track":
```typescript
const trackId = editor.timeline.addTrack({ type: "media" });
```
- This internally creates `AddTrackCommand`, executes it through history
### 🔲 TODO - POLISH
7. **Remove Duplicate Methods**
- `copySelected` exists in both store (UI) and old timeline-store (removed)
- Store version should orchestrate: copy → call `editor.timeline.pasteAtTime()`
8. **Add Type Safety**
- Ensure all commands properly typed
- Ensure all manager methods properly typed
- Fix any remaining linter errors
### Architecture Summary
```
EditorCore (singleton)
├── history: HistoryManager
├── timeline: TimelineManager (pure logic)
│ ├── _tracks (data)
│ ├── Command execution
│ └── Persistence
├── project: ProjectManager
├── media: MediaManager
├── scene: SceneManager
└── playback: PlaybackManager
Timeline Component (UI)
├── dragState (local)
├── TimelineTrackContent
│ ├── dragState (via props)
│ └── TimelineElement
│ └── dragState (via props)
TimelineStore (Zustand - UI state)
├── selectedElements
├── snappingEnabled
├── rippleEditingEnabled
└── clipboard
```
**Key Principle:** Core logic in managers + commands. UI state in store. Ephemeral interaction state in components (dragState).

View File

@ -12,8 +12,32 @@ Review every point below carefully for files to ensure they follow consistent co
10. Use zustand correctly. React component should never use `someStore.getState()`. Instead, use the `useSomeStore` hook.
11. Business logic is in `src/lib` folder. Example: zustand store has a method to remove a bookmark, the method should be a wrapper of a function in `src/lib/` that handles the actual logic.
12. Booleans must be named like `isSomething` or `hasSomething` or `shouldSomething`. Not `something` or `somethingIs`.
13. No text in the codebase ever uses title case. Example: `Hello World` is wrong. It should be `hello world`.
13. No text in docs or UI ever uses title case. Example: `Hello World` is wrong. It should be `Hello world`.
14. Use `size-10` instead of `h-10 w-10` when the width and height are the same.
15. For components that need to subscribe to data from the editor api (`src/core`, `src/managers`), use the `useEditor` hook.
16. In react components: store/manager methods should not be passed as props to sub-components. If a sub-component can access the same methods, it should do so. Example:
```tsx
import { useTimelineStore } from "@/stores/timeline-store";
// ❌ Do NOT do this:
function ParentComponent() {
const { selectedElements } = useTimelineStore();
return <ChildComponent selectedElements={selectedElements} />;
}
function ChildComponent({ selectedElements }) {}
// ✅ Do this:
function ParentComponent() {
return <ChildComponent />;
}
function ChildComponent() {
const { selectedElements } = useTimelineStore();
}
```
17. Components render UI. Domain logic (data transformations, business rules, state mutations) lives in hooks, utilities, or managers. Simple interaction logic (gesture detection, modifier keys) can stay in components if not too many lines of code/complex.
# Functions