summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-28 22:39:19 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-28 22:39:19 +0200
commit2d834a49d7d222d7f70ee6b4cf7ab2827b88bb0e (patch)
treedb8a33eb12c6294cc0000caf05c600fe3c33069e /apps/web/src/components/editor
parent792fb6cdc06ebcd578d9e27821eb25a7dba9dbd2 (diff)
parenta7d90f7eac90966920c750134d7de9b611b2c38f (diff)
downloadopencut-2d834a49d7d222d7f70ee6b4cf7ab2827b88bb0e.tar.gz
Merge branch 'staging'
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx4
-rw-r--r--apps/web/src/components/editor/editor-header.tsx139
-rw-r--r--apps/web/src/components/editor/export-button.tsx268
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx22
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx64
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel/views/stickers.tsx33
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx44
-rw-r--r--apps/web/src/components/editor/onboarding.tsx138
-rw-r--r--apps/web/src/components/editor/panel-base-view.tsx (renamed from apps/web/src/components/editor/media-panel/views/base-view.tsx)37
-rw-r--r--apps/web/src/components/editor/panel-preset-selector.tsx91
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx527
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx8
-rw-r--r--apps/web/src/components/editor/properties-panel/property-item.tsx4
-rw-r--r--apps/web/src/components/editor/properties-panel/text-properties.tsx441
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx30
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx20
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx36
18 files changed, 1341 insertions, 567 deletions
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
index 79bf401d..60406f61 100644
--- a/apps/web/src/components/editor/audio-waveform.tsx
+++ b/apps/web/src/components/editor/audio-waveform.tsx
@@ -67,8 +67,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
});
newWaveSurfer.on("error", (err) => {
- console.error("WaveSurfer error:", err);
if (mounted) {
+ console.error("WaveSurfer error:", err);
setError(true);
setIsLoading(false);
}
@@ -76,8 +76,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
await newWaveSurfer.load(audioUrl);
} catch (err) {
- console.error("Failed to initialize WaveSurfer:", err);
if (mounted) {
+ console.error("Failed to initialize WaveSurfer:", err);
setError(true);
setIsLoading(false);
}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
new file mode 100644
index 00000000..ad2a5b5a
--- /dev/null
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { Button } from "../ui/button";
+import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react";
+import { HeaderBase } from "../header-base";
+import { useProjectStore } from "@/stores/project-store";
+import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
+import { useState } from "react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import Link from "next/link";
+import { RenameProjectDialog } from "../rename-project-dialog";
+import { DeleteProjectDialog } from "../delete-project-dialog";
+import { useRouter } from "next/navigation";
+import { FaDiscord } from "react-icons/fa6";
+import { useTheme } from "next-themes";
+import { PanelPresetSelector } from "./panel-preset-selector";
+import { ExportButton } from "./export-button";
+
+export function EditorHeader() {
+ const { activeProject, renameProject, deleteProject } = useProjectStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const router = useRouter();
+ const { theme, setTheme } = useTheme();
+
+ const handleNameSave = async (newName: string) => {
+ console.log("handleNameSave", newName);
+ if (activeProject && newName.trim() && newName !== activeProject.name) {
+ try {
+ await renameProject(activeProject.id, newName.trim());
+ setIsRenameDialogOpen(false);
+ } catch (error) {
+ console.error("Failed to rename project:", error);
+ }
+ }
+ };
+
+ const handleDelete = () => {
+ if (activeProject) {
+ deleteProject(activeProject.id);
+ setIsDeleteDialogOpen(false);
+ router.push("/projects");
+ }
+ };
+
+ const leftContent = (
+ <div className="flex items-center gap-2">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="secondary"
+ className="h-auto py-1.5 px-2.5 flex items-center justify-center"
+ >
+ <ChevronDown className="text-muted-foreground" />
+ <span className="text-[0.85rem] mr-2">{activeProject?.name}</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start" className="w-40 z-100">
+ <Link href="/projects">
+ <DropdownMenuItem className="flex items-center gap-1.5">
+ <ArrowLeft className="h-4 w-4" />
+ Projects
+ </DropdownMenuItem>
+ </Link>
+ <DropdownMenuItem
+ className="flex items-center gap-1.5"
+ onClick={() => setIsRenameDialogOpen(true)}
+ >
+ <SquarePen className="h-4 w-4" />
+ Rename project
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ variant="destructive"
+ className="flex items-center gap-1.5"
+ onClick={() => setIsDeleteDialogOpen(true)}
+ >
+ <Trash className="h-4 w-4" />
+ Delete Project
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem asChild>
+ <Link
+ href="https://discord.gg/zmR9N35cjK"
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex items-center gap-1.5"
+ >
+ <FaDiscord className="h-4 w-4" />
+ Discord
+ </Link>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ onConfirm={handleNameSave}
+ projectName={activeProject?.name || ""}
+ />
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ onConfirm={handleDelete}
+ projectName={activeProject?.name || ""}
+ />
+ </div>
+ );
+
+ const rightContent = (
+ <nav className="flex items-center gap-2">
+ <PanelPresetSelector />
+ <KeyboardShortcutsHelp />
+ <ExportButton />
+ <Button
+ size="icon"
+ variant="text"
+ className="h-7"
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
+ >
+ <Sun className="!size-[1.1rem]" />
+ <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
+ </Button>
+ </nav>
+ );
+
+ return (
+ <HeaderBase
+ leftContent={leftContent}
+ rightContent={rightContent}
+ className="bg-background h-[3.2rem] px-3 items-center mt-0.5"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
new file mode 100644
index 00000000..c9738e81
--- /dev/null
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+import { useState } from "react";
+import { TransitionUpIcon } from "../icons";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import { Button } from "../ui/button";
+import { Label } from "../ui/label";
+import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
+import { Progress } from "../ui/progress";
+import { Checkbox } from "../ui/checkbox";
+import { cn } from "@/lib/utils";
+import {
+ exportProject,
+ getExportMimeType,
+ getExportFileExtension,
+ DEFAULT_EXPORT_OPTIONS,
+} from "@/lib/export";
+import { useProjectStore } from "@/stores/project-store";
+import { Download, X } from "lucide-react";
+import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
+import { PropertyGroup } from "./properties-panel/property-item";
+
+export function ExportButton() {
+ const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
+ const { activeProject } = useProjectStore();
+
+ const handleExport = () => {
+ setIsExportPopoverOpen(true);
+ };
+
+ const hasProject = !!activeProject;
+
+ return (
+ <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
+ <PopoverTrigger asChild>
+ <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",
+ hasProject
+ ? "cursor-pointer hover:brightness-95"
+ : "cursor-not-allowed opacity-50"
+ )}
+ onClick={hasProject ? handleExport : undefined}
+ disabled={!hasProject}
+ onKeyDown={(event) => {
+ if (hasProject && (event.key === "Enter" || event.key === " ")) {
+ event.preventDefault();
+ handleExport();
+ }
+ }}
+ >
+ <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)]">
+ <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>
+ </div>
+ </div>
+ </button>
+ </PopoverTrigger>
+ {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
+ </Popover>
+ );
+}
+
+function ExportPopover({
+ onOpenChange,
+}: {
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { activeProject } = useProjectStore();
+ const [format, setFormat] = useState<ExportFormat>(
+ DEFAULT_EXPORT_OPTIONS.format
+ );
+ const [quality, setQuality] = useState<ExportQuality>(
+ DEFAULT_EXPORT_OPTIONS.quality
+ );
+ const [includeAudio, setIncludeAudio] = useState<boolean>(
+ DEFAULT_EXPORT_OPTIONS.includeAudio || true
+ );
+ const [isExporting, setIsExporting] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [exportResult, setExportResult] = useState<ExportResult | null>(null);
+
+ const handleExport = async () => {
+ if (!activeProject) return;
+
+ setIsExporting(true);
+ setProgress(0);
+ setExportResult(null);
+
+ const result = await exportProject({
+ format,
+ quality,
+ fps: activeProject.fps,
+ includeAudio,
+ onProgress: setProgress,
+ onCancel: () => false, // TODO: Add cancel functionality
+ });
+
+ setIsExporting(false);
+ setExportResult(result);
+
+ if (result.success && result.buffer) {
+ // Download the file
+ const mimeType = getExportMimeType(format);
+ const extension = getExportFileExtension(format);
+ const blob = new Blob([result.buffer], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${activeProject.name}${extension}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ onOpenChange(false);
+ setExportResult(null);
+ setProgress(0);
+ }
+ };
+
+ const handleClose = () => {
+ if (!isExporting) {
+ onOpenChange(false);
+ setExportResult(null);
+ setProgress(0);
+ }
+ };
+
+ return (
+ <PopoverContent className="w-80 mr-4 flex flex-col gap-3">
+ <>
+ <div className="flex items-center justify-between">
+ <h3 className=" font-medium">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ <Button variant="text" size="icon" onClick={handleClose}>
+ <X className="!size-5 text-foreground/85" />
+ </Button>
+ </div>
+
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col gap-3">
+ <PropertyGroup
+ title="Format"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={format}
+ onValueChange={(value) => setFormat(value as ExportFormat)}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
+
+ <PropertyGroup
+ title="Quality"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) =>
+ setQuality(value as ExportQuality)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very High - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
+
+ <PropertyGroup
+ title="Audio"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="include-audio"
+ checked={includeAudio}
+ onCheckedChange={(checked) => setIncludeAudio(!!checked)}
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
+ </Label>
+ </div>
+ </PropertyGroup>
+ </div>
+
+ <Button onClick={handleExport} className="w-full gap-2">
+ <Download className="w-4 h-4" />
+ Export
+ </Button>
+ </>
+ )}
+
+ {isExporting && (
+ <div className="space-y-4">
+ <div className="flex flex-col">
+ <div className="text-center flex items-center justify-between">
+ <p className="text-sm text-muted-foreground mb-2">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-sm text-muted-foreground mb-2">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
+
+ <Button
+ variant="outline"
+ className="rounded-md w-full"
+ onClick={() => {}}
+ >
+ Cancel
+ </Button>
+ </div>
+ )}
+
+ {exportResult && !exportResult.success && (
+ <div className="text-center space-y-3">
+ <div className="text-red-600 font-medium">Export failed</div>
+ <p className="text-sm text-muted-foreground">
+ {exportResult.error || "Unknown error occurred"}
+ </p>
+ <Button variant="outline" onClick={() => setExportResult(null)}>
+ Try Again
+ </Button>
+ </div>
+ )}
+ </div>
+ </>
+ </PopoverContent>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
index 4fe2cbd7..59403a8d 100644
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ b/apps/web/src/components/editor/media-panel/views/captions.tsx
@@ -1,11 +1,12 @@
import { Button } from "@/components/ui/button";
import { PropertyGroup } from "../../properties-panel/property-item";
-import { BaseView } from "./base-view";
+import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { Language, LanguageSelect } from "@/components/language-select";
import { useState, useRef, useEffect } from "react";
-import { extractTimelineAudio } from "@/lib/ffmpeg-utils";
+import { extractTimelineAudio } from "@/lib/mediabunny-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { useTimelineStore } from "@/stores/timeline-store";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
import {
Dialog,
@@ -162,24 +163,13 @@ export function Captions() {
// Add all caption elements to the same track
shortCaptions.forEach((caption, index) => {
addElementToTrack(captionTrackId, {
- type: "text",
+ ...DEFAULT_TEXT_ELEMENT,
name: `Caption ${index + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: 65,
- fontFamily: "Arial",
- color: "#ffffff",
- textAlign: "center",
- fontWeight: "bold",
- fontStyle: "normal",
- textDecoration: "none",
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
+ fontSize: 65, // Larger for captions
+ fontWeight: "bold", // Bold for captions
} as TextElement);
});
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index c84dde70..07a44a7c 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -2,7 +2,8 @@
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { useMediaStore } from "@/stores/media-store";
+import { MediaFile } from "@/types/media";
import {
ArrowDown01,
CloudUpload,
@@ -13,7 +14,7 @@ import {
Music,
Video,
} from "lucide-react";
-import { useRef, useState, useMemo, useEffect } from "react";
+import { useRef, useState, useMemo } from "react";
import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -47,7 +48,7 @@ function MediaItemWithContextMenu({
children,
onRemove,
}: {
- item: MediaItem;
+ item: MediaFile;
children: React.ReactNode;
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
}) {
@@ -68,14 +69,12 @@ function MediaItemWithContextMenu({
}
export function MediaView() {
- const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
+ const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { mediaViewMode, setMediaViewMode } = usePanelStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const [searchQuery, setSearchQuery] = useState("");
- const [mediaFilter, setMediaFilter] = useState("all");
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
"name"
);
@@ -96,16 +95,13 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
- // Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
- // Add each processed media item to the store
for (const item of processedItems) {
- await addMediaItem(activeProject.id, item);
+ await addMediaFile(activeProject.id, item);
}
} catch (error) {
- // Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
@@ -122,13 +118,11 @@ export function MediaView() {
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- // When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = async (e: React.MouseEvent, id: string) => {
- // Remove a media item from the store
e.stopPropagation();
if (!activeProject) {
@@ -136,8 +130,7 @@ export function MediaView() {
return;
}
- // Media store now handles cascade deletion automatically
- await removeMediaItem(activeProject.id, id);
+ await removeMediaFile(activeProject.id, id);
};
const formatDuration = (duration: number) => {
@@ -147,28 +140,15 @@ export function MediaView() {
return `${min}:${sec.toString().padStart(2, "0")}`;
};
- const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
-
- useEffect(() => {
- let filtered = mediaItems.filter((item) => {
+ const filteredMediaItems = useMemo(() => {
+ let filtered = mediaFiles.filter((item) => {
if (item.ephemeral) return false;
- if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
- return false;
- }
-
- if (
- searchQuery &&
- !item.name.toLowerCase().includes(searchQuery.toLowerCase())
- ) {
- return false;
- }
-
return true;
});
filtered.sort((a, b) => {
- let valueA: any;
- let valueB: any;
+ let valueA: string | number;
+ let valueB: string | number;
switch (sortBy) {
case "name":
@@ -196,8 +176,8 @@ export function MediaView() {
return 0;
});
- setFilteredMediaItems(filtered);
- }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]);
+ return filtered;
+ }, [mediaFiles, sortBy, sortOrder]);
const previewComponents = useMemo(() => {
const previews = new Map<string, React.ReactNode>();
@@ -276,7 +256,7 @@ export function MediaView() {
return previews;
}, [filteredMediaItems]);
- const renderPreview = (item: MediaItem) => previewComponents.get(item.id);
+ const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
return (
<>
@@ -481,12 +461,14 @@ function GridView({
highlightedId,
registerElement,
}: {
- filteredMediaItems: MediaItem[];
- renderPreview: (item: MediaItem) => React.ReactNode;
+ filteredMediaItems: MediaFile[];
+ renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
+ const { addElementAtTime } = useTimelineStore();
+
return (
<div
className="grid gap-2"
@@ -507,7 +489,7 @@ function GridView({
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addMediaAtTime(item, currentTime)
+ addElementAtTime(item, currentTime)
}
rounded={false}
variant="card"
@@ -527,12 +509,14 @@ function ListView({
highlightedId,
registerElement,
}: {
- filteredMediaItems: MediaItem[];
- renderPreview: (item: MediaItem) => React.ReactNode;
+ filteredMediaItems: MediaFile[];
+ renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
+ const { addElementAtTime } = useTimelineStore();
+
return (
<div className="space-y-1">
{filteredMediaItems.map((item) => (
@@ -548,7 +532,7 @@ function ListView({
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addMediaAtTime(item, currentTime)
+ addElementAtTime(item, currentTime)
}
variant="compact"
isHighlighted={highlightedId === item.id}
diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx
index d33b4cce..df1ec54f 100644
--- a/apps/web/src/components/editor/media-panel/views/settings.tsx
+++ b/apps/web/src/components/editor/media-panel/views/settings.tsx
@@ -7,7 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { BaseView } from "./base-view";
+import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
PropertyItem,
PropertyItemLabel,
diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx
index 7d3e073e..41afe285 100644
--- a/apps/web/src/components/editor/media-panel/views/stickers.tsx
+++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx
@@ -35,9 +35,10 @@ import {
ICONIFY_HOSTS,
POPULAR_COLLECTIONS,
} from "@/lib/iconify-api";
-import { cn, generateUUID } from "@/lib/utils";
+import { cn } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import Image from "next/image";
+import type { MediaFile } from "@/types/media";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { InputWithBack } from "@/components/ui/input-with-back";
import { StickerCategory } from "@/stores/stickers-store";
@@ -185,9 +186,9 @@ function EmptyView({ message }: { message: string }) {
function StickersContentView({ category }: { category: StickerCategory }) {
const { activeProject } = useProjectStore();
- const { addMediaAtTime } = useTimelineStore();
+ const { addElementAtTime } = useTimelineStore();
const { currentTime } = usePlaybackStore();
- const { addMediaItem } = useMediaStore();
+ const { addMediaFile } = useMediaStore();
const {
searchQuery,
selectedCollection,
@@ -286,9 +287,9 @@ function StickersContentView({ category }: { category: StickerCategory }) {
throw new Error("Failed to download sticker");
}
- const mediaItem = {
+ const mediaItem: Omit<MediaFile, "id"> = {
name: iconName.replace(":", "-"),
- type: "image" as const,
+ type: "image",
file,
url: URL.createObjectURL(file),
width: 200,
@@ -297,16 +298,16 @@ function StickersContentView({ category }: { category: StickerCategory }) {
ephemeral: false,
};
- await addMediaItem(activeProject.id, mediaItem);
+ await addMediaFile(activeProject.id, mediaItem);
const added = useMediaStore
.getState()
- .mediaItems.find(
+ .mediaFiles.find(
(m) => m.url === mediaItem.url && m.name === mediaItem.name
);
if (!added) throw new Error("Sticker not in media store");
- addMediaAtTime(added, currentTime);
+ addElementAtTime(added, currentTime);
toast.success(`Added "${iconName}" to timeline`);
} catch (error) {
@@ -565,7 +566,12 @@ interface StickerItemProps {
capSize?: boolean;
}
-function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItemProps) {
+function StickerItem({
+ iconName,
+ onAdd,
+ isAdding,
+ capSize = false,
+}: StickerItemProps) {
const [imageError, setImageError] = useState(false);
const [hostIndex, setHostIndex] = useState(0);
@@ -601,7 +607,10 @@ function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItem
className="w-full h-full object-contain"
style={
capSize
- ? { maxWidth: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)" }
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
: undefined
}
onError={() => {
@@ -631,8 +640,8 @@ function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItem
name={displayName}
preview={preview}
dragData={{
- type: "sticker",
- iconName: iconName,
+ id: "sticker-placeholder",
+ type: "image",
name: displayName,
}}
onAddToTimeline={() => onAdd(iconName)}
diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx
index 49eacbc7..22fc2413 100644
--- a/apps/web/src/components/editor/media-panel/views/text.tsx
+++ b/apps/web/src/components/editor/media-panel/views/text.tsx
@@ -1,31 +1,7 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { BaseView } from "./base-view";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { useTimelineStore } from "@/stores/timeline-store";
-import { type TextElement } from "@/types/timeline";
-
-const textData: TextElement = {
- id: "default-text",
- type: "text",
- name: "Default text",
- content: "Default text",
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center" as const,
- fontWeight: "normal" as const,
- fontStyle: "normal" as const,
- textDecoration: "none" as const,
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
-};
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
export function TextView() {
return (
@@ -38,14 +14,20 @@ export function TextView() {
</div>
}
dragData={{
- id: textData.id,
- type: textData.type,
- name: textData.name,
- content: textData.content,
+ id: "temp-text-id",
+ type: DEFAULT_TEXT_ELEMENT.type,
+ name: DEFAULT_TEXT_ELEMENT.name,
+ content: DEFAULT_TEXT_ELEMENT.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addTextAtTime(textData, currentTime)
+ useTimelineStore.getState().addElementAtTime(
+ {
+ ...DEFAULT_TEXT_ELEMENT,
+ id: "temp-text-id",
+ },
+ currentTime
+ )
}
showLabel={false}
/>
diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx
new file mode 100644
index 00000000..8ee60690
--- /dev/null
+++ b/apps/web/src/components/editor/onboarding.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
+import { Button } from "../ui/button";
+import { ArrowRightIcon } from "lucide-react";
+import { useState, useEffect } from "react";
+import ReactMarkdown from "react-markdown";
+
+export function Onboarding() {
+ const [step, setStep] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
+ if (!hasSeenOnboarding) {
+ setIsOpen(true);
+ }
+ }, []);
+
+ const handleNext = () => {
+ setStep(step + 1);
+ };
+
+ const handleClose = () => {
+ setIsOpen(false);
+ localStorage.setItem("hasSeenOnboarding", "true");
+ };
+
+ const getStepTitle = () => {
+ switch (step) {
+ case 0:
+ return "Welcome to OpenCut Beta! 🎉";
+ case 1:
+ return "⚠️ This is a super early beta!";
+ case 2:
+ return "🦋 Have fun testing!";
+ default:
+ return "OpenCut Onboarding";
+ }
+ };
+
+ const renderStepContent = () => {
+ switch (step) {
+ case 0:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="Welcome to OpenCut Beta! 🎉" />
+ <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 1:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title={getStepTitle()} />
+ <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
+ <Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" />
+ <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 2:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title={getStepTitle()} />
+ <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
+ </div>
+ <NextButton onClick={handleClose}>Finish</NextButton>
+ </div>
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleClose}>
+ <DialogContent className="sm:max-w-[425px] !outline-none pt-2">
+ <DialogTitle>
+ <span className="sr-only">{getStepTitle()}</span>
+ </DialogTitle>
+ {renderStepContent()}
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+function Title({ title }: { title: string }) {
+ return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
+}
+
+function Subtitle({ subtitle }: { subtitle: string }) {
+ return <h3 className="text-lg font-medium">{subtitle}</h3>;
+}
+
+function Description({ description }: { description: string }) {
+ return (
+ <div className="text-muted-foreground">
+ <ReactMarkdown
+ components={{
+ p: ({ children }) => <p className="mb-0">{children}</p>,
+ a: ({ href, children }) => (
+ <a
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-foreground hover:text-foreground/80 underline"
+ >
+ {children}
+ </a>
+ ),
+ }}
+ >
+ {description}
+ </ReactMarkdown>
+ </div>
+ );
+}
+
+function NextButton({
+ children,
+ onClick,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+}) {
+ return (
+ <Button onClick={onClick} variant="default" className="w-full">
+ {children}
+ <ArrowRightIcon className="w-4 h-4" />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx
index 2cd33535..ec01028c 100644
--- a/apps/web/src/components/editor/media-panel/views/base-view.tsx
+++ b/apps/web/src/components/editor/panel-base-view.tsx
@@ -2,9 +2,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-interface BaseViewProps {
+interface PanelBaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
+ value?: string;
+ onValueChange?: (value: string) => void;
tabs?: {
value: string;
label: string;
@@ -28,29 +30,38 @@ function ViewContent({
);
}
-export function BaseView({
+export function PanelBaseView({
children,
defaultTab,
+ value,
+ onValueChange,
tabs,
className = "",
ref,
-}: BaseViewProps) {
+}: PanelBaseViewProps) {
return (
<div className={`h-full flex flex-col ${className}`} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
- <Tabs defaultValue={defaultTab} className="flex flex-col h-full">
- <div className="px-3 pt-4 pb-0">
- <TabsList>
- {tabs.map((tab) => (
- <TabsTrigger key={tab.value} value={tab.value}>
- {tab.label}
- </TabsTrigger>
- ))}
- </TabsList>
+ <Tabs
+ defaultValue={defaultTab}
+ value={value}
+ onValueChange={onValueChange}
+ className="flex flex-col h-full"
+ >
+ <div className="sticky top-0 z-10 bg-panel">
+ <div className="px-3 pt-3.5 pb-0">
+ <TabsList>
+ {tabs.map((tab) => (
+ <TabsTrigger key={tab.value} value={tab.value}>
+ {tab.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+ </div>
+ <Separator className="mt-3.5" />
</div>
- <Separator className="mt-4" />
{tabs.map((tab) => (
<TabsContent
key={tab.value}
diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx
new file mode 100644
index 00000000..af22a171
--- /dev/null
+++ b/apps/web/src/components/editor/panel-preset-selector.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { Button } from "../ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
+import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
+
+const PRESET_LABELS: Record<PanelPreset, string> = {
+ default: "Default",
+ media: "Media",
+ inspector: "Inspector",
+ "vertical-preview": "Vertical Preview",
+};
+
+const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
+ default: "Media, preview, and inspector on top row, timeline on bottom",
+ media: "Full height media on left, preview and inspector on top row",
+ inspector: "Full height inspector on right, media and preview on top row",
+ "vertical-preview": "Full height preview on right for vertical videos",
+};
+
+export function PanelPresetSelector() {
+ const { activePreset, setActivePreset, resetPreset } = usePanelStore();
+
+ const handlePresetChange = (preset: PanelPreset) => {
+ setActivePreset(preset);
+ };
+
+ const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
+ event.stopPropagation();
+ resetPreset(preset);
+ };
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="secondary"
+ size="sm"
+ className="h-8 px-2 flex items-center gap-1 text-xs"
+ title="Panel Presets"
+ >
+ <LayoutPanelTop className="h-4 w-4" />
+ <ChevronDown className="h-3 w-3" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start" className="w-64">
+ <div className="px-3 py-2 text-xs font-medium text-muted-foreground">
+ Panel Presets
+ </div>
+ <DropdownMenuSeparator />
+ {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
+ <DropdownMenuItem
+ key={preset}
+ onClick={() => handlePresetChange(preset)}
+ className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
+ >
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-sm">
+ {PRESET_LABELS[preset]}
+ </span>
+ {activePreset === preset && (
+ <div className="h-2 w-2 rounded-full bg-primary" />
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground mt-0.5 leading-tight">
+ {PRESET_DESCRIPTIONS[preset]}
+ </p>
+ </div>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
+ onClick={(e) => handleResetPreset(preset, e)}
+ title={`Reset ${PRESET_LABELS[preset]} preset`}
+ >
+ <RotateCcw className="h-3 w-3" />
+ </Button>
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 42c4becc..1654624c 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -2,19 +2,22 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { useMediaStore } from "@/stores/media-store";
+import { MediaFile } from "@/types/media";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
-import { VideoPlayer } from "@/components/ui/video-player";
-import { AudioPlayer } from "@/components/ui/audio-player";
import { Button } from "@/components/ui/button";
import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
+import { renderTimelineFrame } from "@/lib/timeline-renderer";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
-import { FONT_CLASS_MAP } from "@/lib/font-config";
-import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
+import {
+ DEFAULT_CANVAS_SIZE,
+ DEFAULT_FPS,
+ useProjectStore,
+} from "@/stores/project-store";
import { TextElementDragState } from "@/types/editor";
import {
Popover,
@@ -30,15 +33,27 @@ import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store";
interface ActiveElement {
element: TimelineElement;
track: TimelineTrack;
- mediaItem: MediaItem | null;
+ mediaItem: MediaFile | null;
}
export function PreviewPanel() {
const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
- const { mediaItems } = useMediaStore();
+ const { mediaFiles } = useMediaStore();
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
+ const { isPlaying, volume, muted } = usePlaybackStore();
const { activeProject } = useProjectStore();
const previewRef = useRef<HTMLDivElement>(null);
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const lastFrameTimeRef = useRef(0);
+ const renderSeqRef = useRef(0);
+ const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
+ null
+ );
+
+ const audioContextRef = useRef<AudioContext | null>(null);
+ const audioGainRef = useRef<GainNode | null>(null);
+ const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
+ const playingSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
width: 0,
@@ -249,7 +264,7 @@ export function PreviewPanel() {
mediaItem =
element.mediaId === "test"
? null
- : mediaItems.find((item) => item.id === element.mediaId) ||
+ : mediaFiles.find((item) => item.id === element.mediaId) ||
null;
}
activeElements.push({ element, track, mediaItem });
@@ -262,227 +277,314 @@ export function PreviewPanel() {
const activeElements = getActiveElements();
- // Get media elements for blur background (video/image only)
- const getBlurBackgroundElements = (): ActiveElement[] => {
- return activeElements.filter(
- ({ element, mediaItem }) =>
- element.type === "media" &&
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image") &&
- element.mediaId !== "test" // Exclude test elements
- );
- };
-
- const blurBackgroundElements = getBlurBackgroundElements();
+ // Ensure first frame after mount/seek renders immediately
+ useEffect(() => {
+ const onSeek = () => {
+ lastFrameTimeRef.current = -Infinity;
+ renderSeqRef.current++;
+ };
+ window.addEventListener("playback-seek", onSeek as EventListener);
+ lastFrameTimeRef.current = -Infinity;
+ return () => {
+ window.removeEventListener("playback-seek", onSeek as EventListener);
+ };
+ }, []);
- // Render blur background layer
- const renderBlurBackground = () => {
- if (
- !activeProject?.backgroundType ||
- activeProject.backgroundType !== "blur" ||
- blurBackgroundElements.length === 0
- ) {
- return null;
- }
+ // Web Audio: schedule only on play/pause/seek/volume/mute changes
+ useEffect(() => {
+ const stopAll = () => {
+ for (const src of playingSourcesRef.current) {
+ try {
+ src.stop();
+ } catch {}
+ }
+ playingSourcesRef.current.clear();
+ };
- // Use the first media element for background (could be enhanced to use primary/focused element)
- const backgroundElement = blurBackgroundElements[0];
- const { element, mediaItem } = backgroundElement;
+ type WebAudioWindow = Window & {
+ AudioContext?: typeof AudioContext;
+ webkitAudioContext?: typeof AudioContext;
+ };
+ const ensureAudioGraph = async () => {
+ if (!audioContextRef.current) {
+ const win = window as WebAudioWindow;
+ const Ctx = win.AudioContext ?? win.webkitAudioContext;
+ if (!Ctx) return;
+ audioContextRef.current = new Ctx();
+ }
+ if (!audioGainRef.current) {
+ audioGainRef.current = audioContextRef.current!.createGain();
+ audioGainRef.current.connect(audioContextRef.current!.destination);
+ }
+ if (audioContextRef.current!.state === "suspended") {
+ try {
+ await audioContextRef.current!.resume();
+ } catch {}
+ }
+ const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume));
+ audioGainRef.current!.gain.setValueAtTime(
+ gainValue,
+ audioContextRef.current!.currentTime
+ );
+ };
- if (!mediaItem) return null;
+ const scheduleNow = async () => {
+ await ensureAudioGraph();
+ const audioCtx = audioContextRef.current!;
+ const gain = audioGainRef.current!;
- const blurIntensity = activeProject.blurIntensity || 8;
+ const tracksSnapshot = useTimelineStore.getState().tracks;
+ const mediaList = mediaFiles;
+ const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const));
+ const playbackNow = usePlaybackStore.getState().currentTime;
- if (mediaItem.type === "video") {
- return (
- <div
- key={`blur-${element.id}`}
- className="absolute inset-0 overflow-hidden"
- style={{
- filter: `blur(${blurIntensity}px)`,
- transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
- transformOrigin: "center",
- }}
- >
- <VideoPlayer
- src={mediaItem.url!}
- poster={mediaItem.thumbnailUrl}
- clipStartTime={element.startTime}
- trimStart={element.trimStart}
- trimEnd={element.trimEnd}
- clipDuration={element.duration}
- className="w-full h-full object-cover"
- trackMuted={true}
- />
- </div>
- );
- }
+ const audible: Array<{
+ id: string;
+ elementStart: number;
+ trimStart: number;
+ trimEnd: number;
+ duration: number;
+ muted: boolean;
+ trackMuted: boolean;
+ }> = [];
+ const uniqueIds = new Set<string>();
+ for (const track of tracksSnapshot) {
+ for (const element of track.elements) {
+ if (element.type !== "media") continue;
+ const media = idToMedia.get(element.mediaId);
+ if (!media || media.type !== "audio") continue;
+ const visibleDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ if (visibleDuration <= 0) continue;
+ const localTime = playbackNow - element.startTime + element.trimStart;
+ if (localTime < 0 || localTime >= visibleDuration) continue;
+ audible.push({
+ id: media.id,
+ elementStart: element.startTime,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ duration: element.duration,
+ muted: !!element.muted,
+ trackMuted: !!track.muted,
+ });
+ uniqueIds.add(media.id);
+ }
+ }
- if (mediaItem.type === "image") {
- return (
- <div
- key={`blur-${element.id}`}
- className="absolute inset-0 overflow-hidden"
- style={{
- filter: `blur(${blurIntensity}px)`,
- transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
- transformOrigin: "center",
- }}
- >
- <img
- src={mediaItem.url!}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- draggable={false}
- />
- </div>
- );
- }
+ if (audible.length === 0) return;
- return null;
- };
+ // Decode buffers as needed
+ const decodePromises: Array<Promise<void>> = [];
+ for (const id of uniqueIds) {
+ if (!audioBuffersRef.current.has(id)) {
+ const mediaItem = idToMedia.get(id);
+ if (!mediaItem) continue;
+ const p = (async () => {
+ const arr = await mediaItem.file.arrayBuffer();
+ const buf = await audioCtx.decodeAudioData(arr.slice(0));
+ audioBuffersRef.current.set(id, buf);
+ })();
+ decodePromises.push(p);
+ }
+ }
+ await Promise.all(decodePromises);
- // Render an element
- const renderElement = (elementData: ActiveElement, index: number) => {
- const { element, mediaItem } = elementData;
+ const startAt = audioCtx.currentTime + 0.02;
+ for (const entry of audible) {
+ if (entry.muted || entry.trackMuted) continue;
+ const buffer = audioBuffersRef.current.get(entry.id);
+ if (!buffer) continue;
+ const visibleDuration =
+ entry.duration - entry.trimStart - entry.trimEnd;
+ const localTime = Math.max(
+ 0,
+ playbackNow - entry.elementStart + entry.trimStart
+ );
+ const playDuration = Math.max(0, visibleDuration - localTime);
+ if (playDuration <= 0) continue;
+ const src = audioCtx.createBufferSource();
+ src.buffer = buffer;
+ src.connect(gain);
+ try {
+ src.start(startAt, localTime, playDuration);
+ playingSourcesRef.current.add(src);
+ } catch {}
+ }
+ };
- // Text elements
- if (element.type === "text") {
- const fontClassName =
- FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || "";
+ const onSeek = () => {
+ if (!isPlaying) return;
+ for (const src of playingSourcesRef.current) {
+ try {
+ src.stop();
+ } catch {}
+ }
+ playingSourcesRef.current.clear();
+ void scheduleNow();
+ };
- const scaleRatio = previewDimensions.width / canvasSize.width;
+ // Apply volume/mute changes immediately
+ void ensureAudioGraph();
- return (
- <div
- key={element.id}
- className="absolute cursor-grab"
- onMouseDown={(e) =>
- handleTextMouseDown(e, element, elementData.track.id)
- }
- style={{
- left: `${
- 50 +
- ((dragState.isDragging && dragState.elementId === element.id
- ? dragState.currentX
- : element.x) /
- canvasSize.width) *
- 100
- }%`,
- top: `${
- 50 +
- ((dragState.isDragging && dragState.elementId === element.id
- ? dragState.currentY
- : element.y) /
- canvasSize.height) *
- 100
- }%`,
- transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
- opacity: element.opacity,
- zIndex: 100 + index, // Text elements on top
- }}
- >
- <div
- className={fontClassName}
- style={{
- fontSize: `${element.fontSize * scaleRatio}px`,
- color: element.color,
- backgroundColor: element.backgroundColor,
- textAlign: element.textAlign,
- fontWeight: element.fontWeight,
- fontStyle: element.fontStyle,
- textDecoration: element.textDecoration,
- padding: `${4 * scaleRatio}px ${8 * scaleRatio}px`,
- borderRadius: `${2 * scaleRatio}px`,
- whiteSpace: "nowrap",
- // Fallback for system fonts that don't have classes
- ...(fontClassName === "" && { fontFamily: element.fontFamily }),
- }}
- >
- {element.content}
- </div>
- </div>
- );
+ // Start/stop on play state changes
+ for (const src of playingSourcesRef.current) {
+ try {
+ src.stop();
+ } catch {}
+ }
+ playingSourcesRef.current.clear();
+ if (isPlaying) {
+ void scheduleNow();
}
- // Media elements
- if (element.type === "media") {
- // Test elements
- if (!mediaItem || element.mediaId === "test") {
- return (
- <div
- key={element.id}
- className="absolute inset-0 bg-linear-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
- >
- <div className="text-center">
- <div className="text-2xl mb-2">🎬</div>
- <p className="text-xs text-foreground">{element.name}</p>
- </div>
- </div>
- );
+ window.addEventListener("playback-seek", onSeek as EventListener);
+ return () => {
+ window.removeEventListener("playback-seek", onSeek as EventListener);
+ for (const src of playingSourcesRef.current) {
+ try {
+ src.stop();
+ } catch {}
}
+ playingSourcesRef.current.clear();
+ };
+ }, [isPlaying, volume, muted, mediaFiles]);
- // Video elements
- if (mediaItem.type === "video") {
- return (
- <div
- key={element.id}
- className="absolute inset-0 flex items-center justify-center"
- >
- <VideoPlayer
- src={mediaItem.url!}
- poster={mediaItem.thumbnailUrl}
- clipStartTime={element.startTime}
- trimStart={element.trimStart}
- trimEnd={element.trimEnd}
- clipDuration={element.duration}
- trackMuted={element.muted || elementData.track.muted}
- />
- </div>
- );
+ // Canvas: draw current frame for visible elements using offscreen compositing
+ useEffect(() => {
+ const draw = async () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const mainCtx = canvas.getContext("2d");
+ if (!mainCtx) return;
+
+ // Set canvas internal resolution to avoid blurry scaling
+ const displayWidth = Math.max(1, Math.floor(previewDimensions.width));
+ const displayHeight = Math.max(1, Math.floor(previewDimensions.height));
+ if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
+ canvas.width = displayWidth;
+ canvas.height = displayHeight;
}
- // Image elements
- if (mediaItem.type === "image") {
- return (
- <div
- key={element.id}
- className="absolute inset-0 flex items-center justify-center"
- >
- <img
- src={mediaItem.url!}
- alt={mediaItem.name}
- className="max-w-full max-h-full object-contain"
- draggable={false}
- />
- </div>
- );
+ // Throttle rendering to project FPS during playback only
+ const fps = activeProject?.fps || DEFAULT_FPS;
+ const minDelta = 1 / fps;
+ if (isPlaying) {
+ if (currentTime - lastFrameTimeRef.current < minDelta) {
+ return;
+ }
+ lastFrameTimeRef.current = currentTime;
}
- // Audio elements (no visual representation)
- if (mediaItem.type === "audio") {
- return (
- <div
- key={element.id}
- className="absolute inset-0"
- style={{ pointerEvents: "none" }}
- >
- <AudioPlayer
- src={mediaItem.url!}
- clipStartTime={element.startTime}
- trimStart={element.trimStart}
- trimEnd={element.trimEnd}
- clipDuration={element.duration}
- trackMuted={element.muted || elementData.track.muted}
- />
- </div>
+ // Invalidate older async renders when user scrubs rapidly
+ const mySeq = (renderSeqRef.current += 1);
+
+ // Offscreen buffer to avoid flicker (reuse canvas)
+ if (!offscreenCanvasRef.current) {
+ const hasOffscreen =
+ typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
+ .OffscreenCanvas !== "undefined";
+ if (hasOffscreen) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ offscreenCanvasRef.current = new (globalThis as any).OffscreenCanvas(
+ displayWidth,
+ displayHeight
+ ) as OffscreenCanvas;
+ } else {
+ const c = document.createElement("canvas");
+ c.width = displayWidth;
+ c.height = displayHeight;
+ offscreenCanvasRef.current = c;
+ }
+ }
+ // Ensure size matches
+ if (
+ offscreenCanvasRef.current &&
+ (offscreenCanvasRef.current as HTMLCanvasElement).getContext
+ ) {
+ const c = offscreenCanvasRef.current as HTMLCanvasElement;
+ if (c.width !== displayWidth || c.height !== displayHeight) {
+ c.width = displayWidth;
+ c.height = displayHeight;
+ }
+ } else {
+ const c = offscreenCanvasRef.current as OffscreenCanvas;
+ // @ts-ignore width/height exist on OffscreenCanvas in modern browsers
+ if (
+ (c as unknown as { width: number }).width !== displayWidth ||
+ (c as unknown as { height: number }).height !== displayHeight
+ ) {
+ // @ts-ignore
+ (c as unknown as { width: number }).width = displayWidth;
+ // @ts-ignore
+ (c as unknown as { height: number }).height = displayHeight;
+ }
+ }
+ const offscreenCanvas = offscreenCanvasRef.current as
+ | HTMLCanvasElement
+ | OffscreenCanvas;
+ const offCtx = (offscreenCanvas as HTMLCanvasElement).getContext
+ ? (offscreenCanvas as HTMLCanvasElement).getContext("2d")
+ : (offscreenCanvas as OffscreenCanvas).getContext("2d");
+ if (!offCtx) return;
+
+ await renderTimelineFrame({
+ ctx: offCtx as CanvasRenderingContext2D,
+ time: currentTime,
+ canvasWidth: displayWidth,
+ canvasHeight: displayHeight,
+ tracks,
+ mediaFiles,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? "transparent"
+ : activeProject?.backgroundColor || "#000000",
+ projectCanvasSize: canvasSize,
+ });
+
+ // Blit offscreen to visible canvas
+ mainCtx.clearRect(0, 0, displayWidth, displayHeight);
+ if ((offscreenCanvas as HTMLCanvasElement).getContext) {
+ mainCtx.drawImage(offscreenCanvas as HTMLCanvasElement, 0, 0);
+ } else {
+ mainCtx.drawImage(
+ offscreenCanvas as unknown as CanvasImageSource,
+ 0,
+ 0
);
}
- }
+ };
+
+ void draw();
+ }, [
+ activeElements,
+ currentTime,
+ previewDimensions.width,
+ previewDimensions.height,
+ canvasSize.width,
+ canvasSize.height,
+ activeProject?.backgroundType,
+ activeProject?.backgroundColor,
+ ]);
- return null;
+ // Get media elements for blur background (video/image only)
+ const getBlurBackgroundElements = (): ActiveElement[] => {
+ return activeElements.filter(
+ ({ element, mediaItem }) =>
+ element.type === "media" &&
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image") &&
+ element.mediaId !== "test" // Exclude test elements
+ );
};
+ const blurBackgroundElements = getBlurBackgroundElements();
+
+ // Render blur background layer (handled by canvas now)
+ const renderBlurBackground = () => null;
+
+ // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio.
+ const renderElement = (_elementData: ActiveElement) => null;
+
return (
<>
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative">
@@ -505,14 +607,23 @@ export function PreviewPanel() {
}}
>
{renderBlurBackground()}
+ <canvas
+ ref={canvasRef}
+ style={{
+ position: "absolute",
+ left: 0,
+ top: 0,
+ width: previewDimensions.width,
+ height: previewDimensions.height,
+ }}
+ aria-label="Video preview canvas"
+ />
{activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No elements at current time
</div>
) : (
- activeElements.map((elementData, index) =>
- renderElement(elementData, index)
- )
+ activeElements.map((elementData) => renderElement(elementData))
)}
<LayoutGuideOverlay />
</div>
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx
index 6c79327e..150f9512 100644
--- a/apps/web/src/components/editor/properties-panel/index.tsx
+++ b/apps/web/src/components/editor/properties-panel/index.tsx
@@ -10,7 +10,7 @@ import { SquareSlashIcon } from "lucide-react";
export function PropertiesPanel() {
const { selectedElements, tracks } = useTimelineStore();
- const { mediaItems } = useMediaStore();
+ const { mediaFiles } = useMediaStore();
return (
<>
@@ -28,11 +28,11 @@ export function PropertiesPanel() {
);
}
if (element?.type === "media") {
- const mediaItem = mediaItems.find(
- (item) => item.id === element.mediaId
+ const mediaFile = mediaFiles.find(
+ (file) => file.id === element.mediaId
);
- if (mediaItem?.type === "audio") {
+ if (mediaFile?.type === "audio") {
return <AudioProperties key={elementId} element={element} />;
}
diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx
index dc6b6d74..f7d5dbda 100644
--- a/apps/web/src/components/editor/properties-panel/property-item.tsx
+++ b/apps/web/src/components/editor/properties-panel/property-item.tsx
@@ -57,6 +57,7 @@ interface PropertyGroupProps {
children: React.ReactNode;
defaultExpanded?: boolean;
className?: string;
+ titleClassName?: string;
}
export function PropertyGroup({
@@ -64,6 +65,7 @@ export function PropertyGroup({
children,
defaultExpanded = true,
className,
+ titleClassName,
}: PropertyGroupProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
@@ -73,7 +75,7 @@ export function PropertyGroup({
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
- <PropertyItemLabel className="cursor-pointer">
+ <PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
{title}
</PropertyItemLabel>
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx
index 744c2be5..deaee157 100644
--- a/apps/web/src/components/editor/properties-panel/text-properties.tsx
+++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx
@@ -6,13 +6,26 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import { Switch } from "@/components/ui/switch"; // Add Switch import
import { useState, useRef } from "react";
+import { PanelBaseView } from "@/components/editor/panel-base-view";
+import {
+ TEXT_PROPERTIES_TABS,
+ isTextPropertiesTab,
+ useTextPropertiesStore,
+} from "@/stores/text-properties-store";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { cn, uppercase } from "@/lib/utils";
+import { Grid2x2 } from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
export function TextProperties({
element,
@@ -22,7 +35,8 @@ export function TextProperties({
trackId: string;
}) {
const { updateTextElement } = useTimelineStore();
-
+ const { activeTab, setActiveTab } = useTextPropertiesStore();
+ const containerRef = useRef<HTMLDivElement>(null);
// Local state for input values to allow temporary empty/invalid states
const [fontSizeInput, setFontSizeInput] = useState(
element.fontSize.toString()
@@ -105,199 +119,252 @@ export function TextProperties({
};
return (
- <div className="space-y-6 p-5">
- <Textarea
- placeholder="Name"
- defaultValue={element.content}
- className="min-h-18 resize-none bg-background/50"
- onChange={(e) =>
- updateTextElement(trackId, element.id, { content: e.target.value })
- }
- />
- <PropertyItem direction="row">
- <PropertyItemLabel>Font</PropertyItemLabel>
- <PropertyItemValue>
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value: FontFamily) =>
- updateTextElement(trackId, element.id, { fontFamily: value })
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItem direction="row">
- <PropertyItemLabel>Style</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Button
- variant={element.fontWeight === "bold" ? "default" : "outline"}
- size="sm"
- onClick={() =>
+ <PanelBaseView
+ defaultTab="transform"
+ value={activeTab}
+ onValueChange={(v) => {
+ if (isTextPropertiesTab(v)) setActiveTab(v);
+ }}
+ ref={containerRef}
+ tabs={TEXT_PROPERTIES_TABS.map((t) => ({
+ value: t.value,
+ label: t.label,
+ content:
+ t.value === "transform" ? (
+ <div className="space-y-6"></div>
+ ) : (
+ <div className="space-y-6">
+ <Textarea
+ placeholder="Name"
+ defaultValue={element.content}
+ className="min-h-18 resize-none bg-panel-accent"
+ onChange={(e) =>
updateTextElement(trackId, element.id, {
- fontWeight:
- element.fontWeight === "bold" ? "normal" : "bold",
+ content: e.target.value,
})
}
- className="h-8 px-3 font-bold"
- >
- B
- </Button>
- <Button
- variant={element.fontStyle === "italic" ? "default" : "outline"}
- size="sm"
- onClick={() =>
- updateTextElement(trackId, element.id, {
- fontStyle:
- element.fontStyle === "italic" ? "normal" : "italic",
- })
- }
- className="h-8 px-3 italic"
- >
- I
- </Button>
- <Button
- variant={
- element.textDecoration === "underline" ? "default" : "outline"
- }
- size="sm"
- onClick={() =>
- updateTextElement(trackId, element.id, {
- textDecoration:
- element.textDecoration === "underline"
- ? "none"
- : "underline",
- })
- }
- className="h-8 px-3 underline"
- >
- U
- </Button>
- <Button
- variant={
- element.textDecoration === "line-through"
- ? "default"
- : "outline"
- }
- size="sm"
- onClick={() =>
- updateTextElement(trackId, element.id, {
- textDecoration:
- element.textDecoration === "line-through"
- ? "none"
- : "line-through",
- })
- }
- className="h-8 px-3 line-through"
- >
- S
- </Button>
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItemLabel>Font size</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.fontSize]}
- min={8}
- max={300}
- step={1}
- onValueChange={([value]) => {
- updateTextElement(trackId, element.id, { fontSize: value });
- setFontSizeInput(value.toString());
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={fontSizeInput}
- min={8}
- max={300}
- onChange={(e) => handleFontSizeChange(e.target.value)}
- onBlur={handleFontSizeBlur}
- className="w-12 !text-xs h-7 rounded-sm text-center
+ />
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font</PropertyItemLabel>
+ <PropertyItemValue>
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value: FontFamily) =>
+ updateTextElement(trackId, element.id, {
+ fontFamily: value,
+ })
+ }
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Style</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Button
+ variant={
+ element.fontWeight === "bold" ? "default" : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ updateTextElement(trackId, element.id, {
+ fontWeight:
+ element.fontWeight === "bold" ? "normal" : "bold",
+ })
+ }
+ className="h-8 px-3 font-bold"
+ >
+ B
+ </Button>
+ <Button
+ variant={
+ element.fontStyle === "italic" ? "default" : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ updateTextElement(trackId, element.id, {
+ fontStyle:
+ element.fontStyle === "italic"
+ ? "normal"
+ : "italic",
+ })
+ }
+ className="h-8 px-3 italic"
+ >
+ I
+ </Button>
+ <Button
+ variant={
+ element.textDecoration === "underline"
+ ? "default"
+ : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ updateTextElement(trackId, element.id, {
+ textDecoration:
+ element.textDecoration === "underline"
+ ? "none"
+ : "underline",
+ })
+ }
+ className="h-8 px-3 underline"
+ >
+ U
+ </Button>
+ <Button
+ variant={
+ element.textDecoration === "line-through"
+ ? "default"
+ : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ updateTextElement(trackId, element.id, {
+ textDecoration:
+ element.textDecoration === "line-through"
+ ? "none"
+ : "line-through",
+ })
+ }
+ className="h-8 px-3 line-through"
+ >
+ S
+ </Button>
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font size</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.fontSize]}
+ min={8}
+ max={300}
+ step={1}
+ onValueChange={([value]) => {
+ updateTextElement(trackId, element.id, {
+ fontSize: value,
+ });
+ setFontSizeInput(value.toString());
+ }}
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={fontSizeInput}
+ min={8}
+ max={300}
+ onChange={(e) => handleFontSizeChange(e.target.value)}
+ onBlur={handleFontSizeBlur}
+ className="w-12 px-2 !text-xs h-7 rounded-sm text-center bg-panel-accent
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="row">
- <PropertyItemLabel>Color</PropertyItemLabel>
- <PropertyItemValue>
- <Input
- type="color"
- value={element.color || "#ffffff"}
- onChange={(e) => {
- const color = e.target.value;
- updateTextElement(trackId, element.id, { color });
- }}
- className="w-full cursor-pointer rounded-full"
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <div className="flex items-center justify-between">
- <PropertyItemLabel>Background</PropertyItemLabel>
- <div className="flex items-center space-x-2">
- <Switch
- id="transparent-bg-toggle"
- checked={element.backgroundColor === "transparent"}
- onCheckedChange={handleTransparentToggle}
- />
- <label htmlFor="transparent-bg-toggle" className="text-sm font-medium">
- Transparent
- </label>
- </div>
- </div>
- <PropertyItemValue>
- <Input
- type="color"
- value={
- element.backgroundColor === "transparent"
- ? lastSelectedColor.current
- : element.backgroundColor || "#000000"
- }
- onChange={(e) => handleColorChange(e.target.value)}
- className="w-full cursor-pointer rounded-full"
- disabled={element.backgroundColor === "transparent"}
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Opacity</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.opacity * 100]}
- min={0}
- max={100}
- step={1}
- onValueChange={([value]) => {
- updateTextElement(trackId, element.id, {
- opacity: value / 100,
- });
- setOpacityInput(value.toString());
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={opacityInput}
- min={0}
- max={100}
- onChange={(e) => handleOpacityChange(e.target.value)}
- onBlur={handleOpacityBlur}
- className="w-12 !text-xs h-7 rounded-sm text-center
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Color</PropertyItemLabel>
+ <PropertyItemValue>
+ <ColorPicker
+ value={uppercase(
+ (element.color || "FFFFFF").replace("#", "")
+ )}
+ onChange={(color) => {
+ updateTextElement(trackId, element.id, {
+ color: `#${color}`,
+ });
+ }}
+ containerRef={containerRef}
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Opacity</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.opacity * 100]}
+ min={0}
+ max={100}
+ step={1}
+ onValueChange={([value]) => {
+ updateTextElement(trackId, element.id, {
+ opacity: value / 100,
+ });
+ setOpacityInput(value.toString());
+ }}
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={opacityInput}
+ min={0}
+ max={100}
+ onChange={(e) => handleOpacityChange(e.target.value)}
+ onBlur={handleOpacityBlur}
+ className="w-12 !text-xs h-7 rounded-sm text-center bg-panel-accent
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- </div>
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Background</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <ColorPicker
+ value={uppercase(
+ element.backgroundColor === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : (element.backgroundColor || "#000000").replace(
+ "#",
+ ""
+ )
+ )}
+ onChange={(color) => handleColorChange(`#${color}`)}
+ containerRef={containerRef}
+ className={
+ element.backgroundColor === "transparent"
+ ? "opacity-50 pointer-events-none"
+ : ""
+ }
+ />
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={() =>
+ handleTransparentToggle(
+ element.backgroundColor !== "transparent"
+ )
+ }
+ className="size-9 rounded-full bg-panel-accent p-0 overflow-hidden"
+ >
+ <Grid2x2
+ className={cn(
+ "text-foreground",
+ element.backgroundColor === "transparent" &&
+ "text-primary"
+ )}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Transparent background</TooltipContent>
+ </Tooltip>
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ ),
+ }))}
+ />
);
}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 284384eb..49769989 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -82,13 +82,11 @@ export function Timeline() {
toggleTrackMute,
dragState,
} = useTimelineStore();
- const { mediaItems, addMediaItem } = useMediaStore();
+ const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
- const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
- usePlaybackStore();
+ const { currentTime, duration, seek, setDuration } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
+ const { addElementToNewTrack } = useTimelineStore();
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
@@ -456,10 +454,10 @@ export function Timeline() {
if (dragData.type === "text") {
// Always create new text track to avoid overlaps
- useTimelineStore.getState().addTextToNewTrack(dragData);
+ addElementToNewTrack(dragData);
} else {
// Handle media items
- const mediaItem = mediaItems.find(
+ const mediaItem = mediaFiles.find(
(item: any) => item.id === dragData.id
);
if (!mediaItem) {
@@ -467,7 +465,7 @@ export function Timeline() {
return;
}
- useTimelineStore.getState().addMediaToNewTrack(mediaItem);
+ addElementToNewTrack(mediaItem);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
@@ -480,17 +478,12 @@ export function Timeline() {
return;
}
- setIsProcessing(true);
- setProgress(0);
try {
- const processedItems = await processMediaFiles(
- e.dataTransfer.files,
- (p) => setProgress(p)
- );
+ const processedItems = await processMediaFiles(e.dataTransfer.files);
for (const processedItem of processedItems) {
- await addMediaItem(activeProject.id, processedItem);
- const currentMediaItems = useMediaStore.getState().mediaItems;
- const addedItem = currentMediaItems.find(
+ await addMediaFile(activeProject.id, processedItem);
+ const currentMediaFiles = mediaFiles;
+ const addedItem = currentMediaFiles.find(
(item) =>
item.name === processedItem.name && item.url === processedItem.url
);
@@ -516,9 +509,6 @@ export function Timeline() {
// Show error if file processing fails
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
}
}
};
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index a1a33837..677b9c61 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -40,10 +40,8 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
}: TimelineElementProps) {
- const { mediaItems } = useMediaStore();
+ const { mediaFiles } = useMediaStore();
const {
- updateElementTrim,
- updateElementDuration,
removeElementFromTrack,
removeElementFromTrackWithRipple,
dragState,
@@ -53,12 +51,13 @@ export function TimelineElement({
rippleEditingEnabled,
toggleElementHidden,
toggleElementMuted,
+ copySelected,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const mediaItem =
element.type === "media"
- ? mediaItems.find((item) => item.id === element.mediaId)
+ ? mediaFiles.find((file) => file.id === element.mediaId)
: null;
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
@@ -67,8 +66,6 @@ export function TimelineElement({
element,
track,
zoomLevel,
- onUpdateTrim: updateElementTrim,
- onUpdateDuration: updateElementDuration,
});
const { requestRevealMedia } = useMediaPanelStore.getState();
@@ -120,6 +117,11 @@ export function TimelineElement({
});
};
+ const handleElementCopyContext = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ copySelected();
+ };
+
const handleElementDeleteContext = (e: React.MouseEvent) => {
e.stopPropagation();
if (rippleEditingEnabled) {
@@ -190,7 +192,7 @@ export function TimelineElement({
}
// Render media element ->
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">
@@ -334,6 +336,10 @@ export function TimelineElement({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
+ <ContextMenuItem onClick={handleElementCopyContext}>
+ <Copy className="h-4 w-4 mr-2" />
+ Copy element
+ </ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementContext}>
{hasAudio ? (
isMuted ? (
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 5a037710..ad3ca9b7 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -12,6 +12,7 @@ import {
canElementGoOnTrack,
} from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import type {
TimelineElement as TimelineElementType,
DragData,
@@ -33,7 +34,7 @@ export function TimelineTrackContent({
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}) {
- const { mediaItems } = useMediaStore();
+ const { mediaFiles } = useMediaStore();
const {
tracks,
addTrack,
@@ -537,7 +538,7 @@ export function TimelineTrackContent({
});
} else {
// Media elements
- const mediaItem = mediaItems.find(
+ const mediaItem = mediaFiles.find(
(item) => item.id === dragData.id
);
if (mediaItem) {
@@ -890,29 +891,14 @@ export function TimelineTrackContent({
}
addElementToTrack(targetTrackId, {
- type: "text",
- name: dragData.name || "Text",
- content: dragData.content || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ ...DEFAULT_TEXT_ELEMENT,
+ name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
+ content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
startTime: textSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
});
} else {
// Handle media items
- const mediaItem = mediaItems.find((item) => item.id === dragData.id);
+ const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
if (!mediaItem) {
toast.error("Media item not found");
@@ -1054,7 +1040,7 @@ export function TimelineTrackContent({
} else if (hasFiles) {
// External file drops
const { activeProject } = useProjectStore.getState();
- const { addMediaItem } = useMediaStore.getState();
+ const { addMediaFile } = useMediaStore.getState();
const { addElementToTrack } = useTimelineStore.getState();
if (!activeProject) {
@@ -1066,9 +1052,9 @@ export function TimelineTrackContent({
processMediaFiles(e.dataTransfer.files)
.then(async (processedItems) => {
for (const processedItem of processedItems) {
- await addMediaItem(activeProject.id, processedItem);
- const currentMediaItems = useMediaStore.getState().mediaItems;
- const addedItem = currentMediaItems.find(
+ await addMediaFile(activeProject.id, processedItem);
+ const currentMediaFiles = mediaFiles;
+ const addedItem = currentMediaFiles.find(
(item) =>
item.name === processedItem.name &&
item.url === processedItem.url