This commit is contained in:
Maze Winther
2026-01-05 01:35:10 +01:00
parent d5ca991501
commit c19f085e48
95 changed files with 4133 additions and 4196 deletions
+3 -1
View File
@@ -3,4 +3,6 @@
# Env vars
.env*
!.env.example
!.env.example
.next/
-1
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",
+7
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);
+42 -79
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"
@@ -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,
@@ -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 || ""}
/>
</>
@@ -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 />
@@ -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>;
}
@@ -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>;
}
@@ -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"
/>
);
}
@@ -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>
);
}
@@ -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` }}
/>
);
}
@@ -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 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"}
@@ -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>
@@ -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" })}
<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"
>
<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>
<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>
);
@@ -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}
/>
);
})}
@@ -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";
@@ -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);
+21 -25
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();
+2 -9
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,
+3 -2
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],
+3
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);
+44
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 = [];
}
}
+4 -30
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);
@@ -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;
+74 -5
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,
}: {
+15 -12
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);
+2 -2
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) {}
+169 -313
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();
}
}
@@ -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,
};
}
@@ -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,
};
}
@@ -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,
@@ -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
};
}
+22
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;
}
+121
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,
};
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from "react";
interface UseFilePasteOptions {
onFilesPaste: (files: File[]) => void;
}
export function useFilePaste({ onFilesPaste }: UseFilePasteOptions) {
useEffect(() => {
function handlePaste(e: ClipboardEvent) {
if (!e.clipboardData?.files.length) return;
const files = Array.from(e.clipboardData.files);
if (files.length > 0) {
e.preventDefault();
onFilesPaste(files);
}
}
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [onFilesPaste]);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState, useRef } from "react";
import { hasAssetDragData } from "@/lib/asset-drag";
interface UseFileUploadOptions {
accept?: string;
multiple?: boolean;
onFilesSelected?: (files: FileList) => void;
}
function containsFiles(dataTransfer: DataTransfer): boolean {
return (
!hasAssetDragData({ dataTransfer }) && dataTransfer.types.includes("Files")
);
}
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();
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files;
if (files && files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
if (event.target) {
event.target.value = "";
}
}
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);
}
}
function handleDrop(e: React.DragEvent) {
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) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(files[0]);
onFilesSelected(dataTransfer.files);
}
}
}
return {
isDragOver,
openFilePicker,
fileInputProps: {
ref: inputRef,
type: "file",
style: { display: "none" },
onChange: handleFileChange,
},
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
+30
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);
}
+11
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();
}
}
+2
View File
@@ -0,0 +1,2 @@
export { Command } from "./base-command";
export * from "./timeline";
@@ -0,0 +1 @@
export { PasteCommand } from "./paste";
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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";
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,3 @@
export * from "./track";
export * from "./element";
export * from "./clipboard";
@@ -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;
}
}
@@ -0,0 +1,3 @@
export { AddTrackCommand } from "./add-track";
export { RemoveTrackCommand } from "./remove-track";
export { ToggleTrackMuteCommand } from "./toggle-track-mute";
@@ -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);
}
}
}
@@ -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);
}
}
}
+142 -107
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}`;
}
+3 -15
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({
+3 -1
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,
};
}
+1
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
}
@@ -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);
}
+213
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;
}
+72 -1
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,
};
}
+23
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);
}
+27 -48
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 };
@@ -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";
-295
View File
@@ -1,295 +0,0 @@
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
import { MediaType, MediaFile } from "@/types/media";
import { videoCache } from "@/lib/video-cache";
interface MediaStore {
mediaFiles: MediaFile[];
isLoading: boolean;
// Actions
addMediaFile: (
projectId: string,
file: Omit<MediaFile, "id">
) => Promise<void>;
removeMediaFile: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>;
clearProjectMedia: (projectId: string) => Promise<void>;
clearAllMedia: () => void;
}
// Helper function to determine file type
export const getFileType = (file: File): MediaType | null => {
const { type } = file;
if (type.startsWith("image/")) {
return "image";
}
if (type.startsWith("video/")) {
return "video";
}
if (type.startsWith("audio/")) {
return "audio";
}
return null;
};
// Helper function to get image dimensions
export const getImageDimensions = (
file: File
): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.addEventListener("load", () => {
const width = img.naturalWidth;
const height = img.naturalHeight;
resolve({ width, height });
img.remove();
});
img.addEventListener("error", () => {
reject(new Error("Could not load image"));
img.remove();
});
img.src = URL.createObjectURL(file);
});
};
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"
) as HTMLVideoElement;
element.addEventListener("loadedmetadata", () => {
resolve(element.duration);
element.remove();
});
element.addEventListener("error", () => {
reject(new Error("Could not load media"));
element.remove();
});
element.src = URL.createObjectURL(file);
element.load();
});
};
export const getMediaAspectRatio = (item: MediaFile): number => {
if (item.width && item.height) {
return item.width / item.height;
}
return 16 / 9; // Default aspect ratio
};
export const useMediaStore = create<MediaStore>((set, get) => ({
mediaFiles: [],
isLoading: false,
addMediaFile: async (projectId, file) => {
const newItem: MediaFile = {
...file,
id: generateUUID(),
};
// Add to local state immediately for UI responsiveness
set((state) => ({
mediaFiles: [...state.mediaFiles, newItem],
}));
// Save to persistent storage in background
try {
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
} catch (error) {
console.error("Failed to save media item:", error);
// Remove from local state if save failed
set((state) => ({
mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id),
}));
}
},
removeMediaFile: async (projectId: string, id: string) => {
const state = get();
const item = state.mediaFiles.find((media) => media.id === id);
videoCache.clearVideo(id);
// Cleanup object URLs to prevent memory leaks
if (item?.url) {
URL.revokeObjectURL(item.url);
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
}
// 1) Remove from local state immediately
set((state) => ({
mediaFiles: state.mediaFiles.filter((media) => media.id !== id),
}));
// 2) Cascade into the timeline: remove any elements using this media ID
const timeline = useTimelineStore.getState();
const { tracks, deleteSelected, setSelectedElements } = timeline;
// Find all elements that reference this media
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of tracks) {
for (const el of track.elements) {
if (el.type === "media" && el.mediaId === id) {
elementsToRemove.push({ trackId: track.id, elementId: el.id });
}
}
}
// If there are elements to remove, use unified delete function
if (elementsToRemove.length > 0) {
setSelectedElements(elementsToRemove);
deleteSelected();
}
// 3) Remove from persistent storage
try {
await storageService.deleteMediaFile({ projectId, id });
} catch (error) {
console.error("Failed to delete media item:", error);
}
},
loadProjectMedia: async (projectId) => {
set({ isLoading: true });
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 });
} catch (error) {
console.error("Failed to load media items:", error);
} finally {
set({ isLoading: false });
}
},
clearProjectMedia: async (projectId) => {
const state = get();
// Cleanup all object URLs
state.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
// Clear local state
set({ mediaFiles: [] });
// Clear persistent storage
try {
const mediaIds = state.mediaFiles.map((item) => item.id);
await Promise.all(
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id }))
);
} catch (error) {
console.error("Failed to clear media items from storage:", error);
}
},
clearAllMedia: () => {
const state = get();
videoCache.clearAll();
// Cleanup all object URLs
state.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
// Clear local state
set({ mediaFiles: [] });
},
}));
-198
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();
}
},
}));
-465
View File
@@ -1,465 +0,0 @@
import { TProject, TScene } from "@/types/project";
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
import { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils";
import { TCanvasSize } from "@/types/editor";
import { DEFAULT_BLUR_INTENSITY, DEFAULT_CANVAS_SIZE, DEFAULT_FPS } from "@/constants/editor-constants";
export function createMainScene(): TScene {
return {
id: generateUUID(),
name: "Main Scene",
isMain: true,
createdAt: new Date(),
updatedAt: new Date(),
};
}
const createDefaultProject = (name: string): TProject => {
const mainScene: TScene = createMainScene();
return {
id: generateUUID(),
name,
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
scenes: [mainScene],
currentSceneId: mainScene.id,
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: DEFAULT_BLUR_INTENSITY,
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
};
};
interface ProjectStore {
activeProject: TProject | null;
savedProjects: TProject[];
isLoading: boolean;
isInitialized: boolean;
invalidProjectIds?: Set<string>;
createNewProject: (name: string) => Promise<string>;
loadProject: (id: string) => Promise<void>;
saveCurrentProject: () => Promise<void>;
loadAllProjects: () => Promise<void>;
deleteProject: (id: string) => Promise<void>;
closeProject: () => void;
renameProject: (projectId: string, name: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<string>;
updateProjectBackground: (backgroundColor: string) => Promise<void>;
updateBackgroundType: (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number },
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
updateCanvasSize: ({ size }: { size: TCanvasSize }) => Promise<void>;
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string,
) => TProject[];
isInvalidProjectId: (id: string) => boolean;
markProjectIdAsInvalid: (id: string) => void;
clearInvalidProjectIds: () => void;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
savedProjects: [],
isLoading: true,
isInitialized: false,
invalidProjectIds: new Set<string>(),
createNewProject: async (name: string) => {
const newProject = createDefaultProject(name);
set({ activeProject: newProject });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.initializeScenes({
scenes: newProject.scenes,
currentSceneId: newProject.currentSceneId,
});
try {
await storageService.saveProject({ project: newProject });
// Reload all projects to update the list
await get().loadAllProjects();
return newProject.id;
} catch (error) {
toast.error("Failed to save new project");
throw error;
}
},
loadProject: async (id: string) => {
if (!get().isInitialized) {
set({ isLoading: true });
}
// Prevent flicker when switching projects - clear all stores
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.clearScenes();
try {
const project = await storageService.loadProject({ id });
if (project) {
set({ activeProject: project });
let currentScene = null;
if (project.scenes && project.scenes.length > 0) {
sceneStore.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
// Get current scene directly from project data (don't rely on store state)
currentScene =
project.scenes.find((s) => s.id === project.currentSceneId) ||
project.scenes.find((s) => s.isMain) ||
project.scenes[0];
}
await Promise.all([
mediaStore.loadProjectMedia(id),
timelineStore.loadProjectTimeline({
projectId: id,
sceneId: currentScene?.id,
}),
]);
} else {
throw new Error(`Project with id ${id} not found`);
}
} catch (error) {
console.error("Failed to load project:", error);
throw error; // Re-throw so the editor page can handle it
} finally {
set({ isLoading: false });
}
},
saveCurrentProject: async () => {
const { activeProject } = get();
if (!activeProject) return;
try {
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
const currentScene = sceneStore.currentScene;
await Promise.all([
storageService.saveProject({ project: activeProject }),
timelineStore.saveProjectTimeline({
projectId: activeProject.id,
sceneId: currentScene?.id,
}),
]);
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to save project:", error);
}
},
loadAllProjects: async () => {
if (!get().isInitialized) {
set({ isLoading: true });
}
try {
const projects = await storageService.loadAllProjects();
set({ savedProjects: projects });
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
set({ isLoading: false, isInitialized: true });
}
},
deleteProject: async (id: string) => {
try {
await Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProjectTimeline({ projectId: id }),
storageService.deleteProject({ id }),
]);
await get().loadAllProjects(); // Refresh the list
// If deleted active project, close it and clear data
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.clearScenes();
}
} catch (error) {
console.error("Failed to delete project:", error);
}
},
closeProject: () => {
set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.clearScenes();
},
renameProject: async (id: string, name: string) => {
const { savedProjects } = get();
// Find the project to rename
const projectToRename = savedProjects.find((p) => p.id === id);
if (!projectToRename) {
toast.error("Project not found", {
description: "Please try again",
});
return;
}
const updatedProject = {
...projectToRename,
name,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
await get().loadAllProjects();
// Update activeProject if same project
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: updatedProject });
}
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
},
duplicateProject: async (projectId: string) => {
try {
const project = await storageService.loadProject({ id: projectId });
if (!project) {
toast.error("Project not found", {
description: "Please try again",
});
throw new Error("Project not found");
}
const { savedProjects } = get();
// Extract the base name (remove any existing numbering)
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
const baseName = numberMatch ? numberMatch[2] : project.name;
const existingNumbers: number[] = [];
// Check for pattern "(number) baseName" in existing projects
savedProjects.forEach((p) => {
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
if (match && match[2] === baseName) {
existingNumbers.push(parseInt(match[1], 10));
}
});
const nextNumber =
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
...project,
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
};
await storageService.saveProject({ project: newProject });
await get().loadAllProjects();
return newProject.id;
} catch (error) {
console.error("Failed to duplicate project:", error);
toast.error("Failed to duplicate project", {
description:
error instanceof Error ? error.message : "Please try again",
});
throw error;
}
},
updateProjectBackground: async (backgroundColor: string) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundColor,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} catch (error) {
console.error("Failed to update project background:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateBackgroundType: async (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number },
) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundType: type,
...(options?.backgroundColor && {
backgroundColor: options.backgroundColor,
}),
...(options?.blurIntensity !== undefined && {
blurIntensity: options.blurIntensity,
}),
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} catch (error) {
console.error("Failed to update background type:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateProjectFps: async (fps: number) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
fps,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} catch (error) {
console.error("Failed to update project FPS:", error);
toast.error("Failed to update project FPS", {
description: "Please try again",
});
}
},
updateCanvasSize: async ({ size }: { size: TCanvasSize }) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject: TProject = {
...activeProject,
canvasSize: size,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} catch (error) {
console.error("Failed to update canvas size:", error);
toast.error("Failed to update canvas size", {
description: "Please try again",
});
}
},
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
const { savedProjects } = get();
const filteredProjects = savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const sortedProjects = [...filteredProjects].sort((a, b) => {
const [key, order] = sortOption.split("-");
if (key !== "createdAt" && key !== "name") {
console.warn(`Invalid sort key: ${key}`);
return 0;
}
const aValue = a[key];
const bValue = b[key];
if (aValue === undefined || bValue === undefined) return 0;
if (order === "asc") {
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
return 0;
}
if (aValue > bValue) return -1;
if (aValue < bValue) return 1;
return 0;
});
return sortedProjects;
},
// Global invalid project ID tracking
isInvalidProjectId: (id: string) => {
const invalidIds = get().invalidProjectIds || new Set();
return invalidIds.has(id);
},
markProjectIdAsInvalid: (id: string) => {
set((state) => ({
invalidProjectIds: new Set([
...(state.invalidProjectIds || new Set()),
id,
]),
}));
},
clearInvalidProjectIds: () => {
set({ invalidProjectIds: new Set() });
},
}));
-12
View File
@@ -1,12 +0,0 @@
import { create } from "zustand";
import { SceneNode } from "@/services/renderer/nodes/root-node";
interface RendererStore {
renderTree: SceneNode | null;
setRenderTree: (renderTree: SceneNode | null) => void;
}
export const useRendererStore = create<RendererStore>((set) => ({
renderTree: null,
setRenderTree: (renderTree) => set({ renderTree }),
}));
-419
View File
@@ -1,419 +0,0 @@
import { create } from "zustand";
import { TScene } from "@/types/project";
import { useProjectStore } from "./project-store";
import { useTimelineStore } from "./timeline-store";
import { storageService } from "@/lib/storage/storage-service";
import {
getActiveScene,
updateSceneInArray,
getMainScene as getMainSceneUtil,
ensureMainScene,
createScene as createSceneUtil,
canDeleteScene,
getFallbackSceneAfterDelete,
normalizeScenes,
findCurrentScene,
} from "@/lib/scene-utils";
import { toast } from "sonner";
import {
getFrameTime,
toggleBookmarkInArray,
removeBookmarkFromArray,
isBookmarkAtTime,
} from "@/lib/timeline/bookmark-utils";
interface SceneStore {
currentScene: TScene | null;
scenes: TScene[];
activeScene: TScene | null;
createScene: ({
name,
isMain,
}: {
name: string;
isMain: boolean;
}) => Promise<string>;
deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
renameScene: ({
sceneId,
name,
}: {
sceneId: string;
name: string;
}) => Promise<void>;
switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
toggleBookmark: ({ time }: { time: number }) => Promise<void>;
isBookmarked: ({ time }: { time: number }) => boolean;
removeBookmark: ({ time }: { time: number }) => Promise<void>;
loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>;
initializeScenes: ({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId?: string;
}) => void;
clearScenes: () => void;
}
export const useSceneStore = create<SceneStore>((set, get) => {
const updateProjectWithScenes = async ({
updatedScenes,
updatedSceneId,
refreshProjectList = false,
}: {
updatedScenes: TScene[];
updatedSceneId?: string;
refreshProjectList?: boolean;
}) => {
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeProject) {
throw new Error("No active project");
}
const updatedScene = updatedSceneId
? updatedScenes.find((s) => s.id === updatedSceneId)
: get().currentScene;
const updatedProject = {
...activeProject,
scenes: updatedScenes,
updatedAt: new Date(),
};
await storageService.saveProject({ project: updatedProject });
useProjectStore.setState({ activeProject: updatedProject });
set({ scenes: updatedScenes, currentScene: updatedScene || null });
if (refreshProjectList) {
await projectStore.loadAllProjects();
}
};
return {
currentScene: null,
scenes: [],
get activeScene(): TScene | null {
const { scenes, currentScene } = get();
return getActiveScene({
scenes,
currentSceneId: currentScene?.id || "",
});
},
createScene: async ({ name, isMain = false }) => {
const { scenes } = get();
const newScene = createSceneUtil({ name, isMain });
const updatedScenes = [...scenes, newScene];
try {
await updateProjectWithScenes({ updatedScenes });
return newScene.id;
} catch (error) {
console.error("Failed to create scene:", error);
throw error;
}
},
deleteScene: async ({ sceneId }: { sceneId: string }) => {
const { scenes, currentScene } = get();
const sceneToDelete = scenes.find((s) => s.id === sceneId);
if (!sceneToDelete) {
throw new Error("Scene not found");
}
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
if (!canDelete) {
throw new Error(reason);
}
const updatedScenes = scenes.filter((s) => s.id !== sceneId);
const newCurrentScene = getFallbackSceneAfterDelete({
scenes: updatedScenes,
deletedSceneId: sceneId,
currentSceneId: currentScene?.id || null,
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: newCurrentScene?.id,
});
if (newCurrentScene && newCurrentScene.id !== currentScene?.id) {
const timelineStore = useTimelineStore.getState();
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (activeProject) {
await timelineStore.loadProjectTimeline({
projectId: activeProject.id,
sceneId: newCurrentScene.id,
});
}
}
} catch (error) {
console.error("Failed to delete scene:", error);
throw error;
}
},
renameScene: async ({
sceneId,
name,
}: {
sceneId: string;
name: string;
}) => {
const { scenes } = get();
const updatedScenes = updateSceneInArray({
scenes,
sceneId,
updates: { name, updatedAt: new Date() },
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: sceneId,
});
} catch (error) {
console.error("Failed to rename scene:", error);
throw error;
}
},
switchToScene: async ({ sceneId }: { sceneId: string }) => {
const { scenes } = get();
const targetScene = scenes.find((s) => s.id === sceneId);
if (!targetScene) {
throw new Error("Scene not found");
}
const timelineStore = useTimelineStore.getState();
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
const { currentScene } = get();
if (activeProject && currentScene) {
await timelineStore.saveProjectTimeline({
projectId: activeProject.id,
sceneId: currentScene.id,
});
}
if (activeProject) {
await timelineStore.loadProjectTimeline({
projectId: activeProject.id,
sceneId,
});
const updatedProject = {
...activeProject,
currentSceneId: sceneId,
updatedAt: new Date(),
};
await storageService.saveProject({ project: updatedProject });
useProjectStore.setState({ activeProject: updatedProject });
}
set({ currentScene: targetScene });
},
toggleBookmark: async ({ time }: { time: number }) => {
const { activeScene, scenes, currentScene } = get();
if (!activeScene || !currentScene) return;
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
const updatedBookmarks = toggleBookmarkInArray({
bookmarks,
frameTime,
});
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: {
timeline: {
...activeScene.timeline,
bookmarks: updatedBookmarks,
},
},
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
refreshProjectList: true,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to update bookmarks", {
description: "Please try again",
});
}
},
isBookmarked: ({ time }: { time: number }) => {
const { activeScene, currentScene } = get();
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeScene || !currentScene || !activeProject) return false;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
return isBookmarkAtTime({ bookmarks, frameTime });
},
removeBookmark: async ({ time }: { time: number }) => {
const { activeScene, scenes, currentScene } = get();
if (!activeScene || !currentScene) return;
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
const updatedBookmarks = removeBookmarkFromArray({
bookmarks,
frameTime,
});
if (updatedBookmarks.length === bookmarks.length) {
return;
}
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: {
timeline: {
...activeScene.timeline,
bookmarks: updatedBookmarks,
},
},
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
refreshProjectList: true,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to remove bookmark", {
description: "Please try again",
});
}
},
getMainScene: () => {
const { scenes } = get();
return getMainSceneUtil({ scenes });
},
getCurrentScene: () => {
return get().currentScene;
},
loadProjectScenes: async ({ projectId }: { projectId: string }) => {
try {
const project = await storageService.loadProject({ id: projectId });
if (project?.scenes) {
const normalizedScenes = normalizeScenes({ scenes: project.scenes });
const currentScene = findCurrentScene({
scenes: normalizedScenes,
currentSceneId: project.currentSceneId,
});
set({
scenes: normalizedScenes,
currentScene,
});
}
} catch (error) {
console.error("Failed to load project scenes:", error);
set({ scenes: [], currentScene: null });
}
},
initializeScenes: ({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId?: string;
}) => {
const ensuredScenes = ensureMainScene({ scenes });
const currentScene = currentSceneId
? ensuredScenes.find((s) => s.id === currentSceneId)
: null;
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
set({
scenes: ensuredScenes,
currentScene: currentScene || fallbackScene,
});
if (ensuredScenes.length > scenes.length) {
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: ensuredScenes,
updatedAt: new Date(),
};
storageService
.saveProject({ project: updatedProject })
.then(() => {
useProjectStore.setState({ activeProject: updatedProject });
})
.catch((error) => {
console.error(
"Failed to save project with background scene:",
error,
);
});
}
}
},
clearScenes: () => {
set({
scenes: [],
currentScene: null,
});
},
};
});
+1 -1
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;
}
+3 -3
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
+34
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;
-14
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";
-17
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;
}
+83 -56
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;
}
+1 -1
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"]
}