summaryrefslogtreecommitdiff
path: root/apps/web
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
parent792fb6cdc06ebcd578d9e27821eb25a7dba9dbd2 (diff)
parenta7d90f7eac90966920c750134d7de9b611b2c38f (diff)
downloadopencut-2d834a49d7d222d7f70ee6b4cf7ab2827b88bb0e.tar.gz
Merge branch 'staging'
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx16
-rw-r--r--apps/web/src/components/background-settings.tsx184
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx4
-rw-r--r--apps/web/src/components/editor/editor-header.tsx (renamed from apps/web/src/components/editor-header.tsx)12
-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.tsx (renamed from apps/web/src/components/onboarding.tsx)4
-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.tsx (renamed from apps/web/src/components/panel-preset-selector.tsx)8
-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
-rw-r--r--apps/web/src/components/export-button.tsx185
-rw-r--r--apps/web/src/components/header.tsx2
-rw-r--r--apps/web/src/components/landing/hero.tsx2
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx (renamed from apps/web/src/components/editor-provider.tsx)0
-rw-r--r--apps/web/src/components/rename-project-dialog.tsx2
-rw-r--r--apps/web/src/components/ui/color-picker.tsx336
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx3
-rw-r--r--apps/web/src/components/ui/font-picker.tsx2
-rw-r--r--apps/web/src/components/ui/progress.tsx4
-rw-r--r--apps/web/src/components/ui/sonner.tsx4
-rw-r--r--apps/web/src/components/ui/textarea.tsx4
-rw-r--r--apps/web/src/constants/actions.ts4
-rw-r--r--apps/web/src/constants/text-constants.ts27
-rw-r--r--apps/web/src/hooks/use-aspect-ratio.ts12
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts17
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts8
-rw-r--r--apps/web/src/hooks/use-selection-box.ts16
-rw-r--r--apps/web/src/hooks/use-timeline-element-resize.ts21
-rw-r--r--apps/web/src/lib/export.ts296
-rw-r--r--apps/web/src/lib/media-processing.ts30
-rw-r--r--apps/web/src/lib/mediabunny-utils.ts (renamed from apps/web/src/lib/ffmpeg-utils.ts)303
-rw-r--r--apps/web/src/lib/storage/storage-service.ts18
-rw-r--r--apps/web/src/lib/timeline-renderer.ts163
-rw-r--r--apps/web/src/lib/utils.ts4
-rw-r--r--apps/web/src/stores/keybindings-store.ts4
-rw-r--r--apps/web/src/stores/media-store.ts78
-rw-r--r--apps/web/src/stores/sounds-store.ts6
-rw-r--r--apps/web/src/stores/text-properties-store.ts33
-rw-r--r--apps/web/src/stores/timeline-store.ts285
-rw-r--r--apps/web/src/types/export.ts18
-rw-r--r--apps/web/src/types/media.ts17
-rw-r--r--apps/web/src/types/timeline.ts2
52 files changed, 2256 insertions, 1414 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 4d5a27fc..1b53b6b5 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -6,17 +6,17 @@ import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
-} from "../../../components/ui/resizable";
-import { MediaPanel } from "../../../components/editor/media-panel";
-import { PropertiesPanel } from "../../../components/editor/properties-panel";
-import { Timeline } from "../../../components/editor/timeline";
-import { PreviewPanel } from "../../../components/editor/preview-panel";
-import { EditorHeader } from "@/components/editor-header";
+} from "@/components/ui/resizable";
+import { MediaPanel } from "@/components/editor/media-panel";
+import { PropertiesPanel } from "@/components/editor/properties-panel";
+import { Timeline } from "@/components/editor/timeline";
+import { PreviewPanel } from "@/components/editor/preview-panel";
+import { EditorHeader } from "@/components/editor/editor-header";
import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
-import { EditorProvider } from "@/components/editor-provider";
+import { EditorProvider } from "@/components/providers/editor-provider";
import { usePlaybackControls } from "@/hooks/use-playback-controls";
-import { Onboarding } from "@/components/onboarding";
+import { Onboarding } from "@/components/editor/onboarding";
export default function Editor() {
const {
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx
deleted file mode 100644
index 0457cb05..00000000
--- a/apps/web/src/components/background-settings.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
-import { Button } from "./ui/button";
-import { BackgroundIcon } from "./icons";
-import { cn } from "@/lib/utils";
-import Image from "next/image";
-import { colors } from "@/data/colors/solid";
-import { useProjectStore } from "@/stores/project-store";
-import { PipetteIcon } from "lucide-react";
-
-type BackgroundTab = "color" | "blur";
-
-export function BackgroundSettings() {
- const { activeProject, updateBackgroundType } = useProjectStore();
-
- // ✅ Good: derive activeTab from activeProject during rendering
- const activeTab = activeProject?.backgroundType || "color";
-
- const handleColorSelect = (color: string) => {
- updateBackgroundType("color", { backgroundColor: color });
- };
-
- const handleBlurSelect = (blurIntensity: number) => {
- updateBackgroundType("blur", { blurIntensity });
- };
-
- const tabs = [
- {
- label: "Color",
- value: "color",
- },
- {
- label: "Blur",
- value: "blur",
- },
- ];
-
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="text"
- size="icon"
- className="size-4! border border-muted-foreground"
- >
- <BackgroundIcon className="size-3!" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="flex flex-col items-start w-[20rem] h-64 overflow-hidden p-0">
- <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
- <h2 className="text-sm">Background</h2>
- <div className="flex items-center gap-2 text-sm">
- {tabs.map((tab) => (
- <span
- key={tab.value}
- onClick={() => {
- // Switch to the background type when clicking tabs
- if (tab.value === "color") {
- updateBackgroundType("color", {
- backgroundColor:
- activeProject?.backgroundColor || "#000000",
- });
- } else {
- updateBackgroundType("blur", {
- blurIntensity: activeProject?.blurIntensity || 8,
- });
- }
- }}
- className={cn(
- "text-muted-foreground cursor-pointer",
- activeTab === tab.value && "text-foreground"
- )}
- >
- {tab.label}
- </span>
- ))}
- </div>
- </div>
- {activeTab === "color" ? (
- <ColorView
- selectedColor={activeProject?.backgroundColor || "#000000"}
- onColorSelect={handleColorSelect}
- />
- ) : (
- <BlurView
- selectedBlur={activeProject?.blurIntensity || 8}
- onBlurSelect={handleBlurSelect}
- />
- )}
- </PopoverContent>
- </Popover>
- );
-}
-
-function ColorView({
- selectedColor,
- onColorSelect,
-}: {
- selectedColor: string;
- onColorSelect: (color: string) => void;
-}) {
- return (
- <div className="w-full h-full">
- <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-linear-to-b from-popover to-transparent pointer-events-none" />
- <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
- <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
- <PipetteIcon className="size-4" />
- </div>
- {colors.map((color) => (
- <ColorItem
- key={color}
- color={color}
- isSelected={color === selectedColor}
- onClick={() => onColorSelect(color)}
- />
- ))}
- </div>
- </div>
- );
-}
-
-function ColorItem({
- color,
- isSelected,
- onClick,
-}: {
- color: string;
- isSelected: boolean;
- onClick: () => void;
-}) {
- return (
- <div
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
- isSelected && "border-2 border-primary"
- )}
- style={{ backgroundColor: color }}
- onClick={onClick}
- />
- );
-}
-
-function BlurView({
- selectedBlur,
- onBlurSelect,
-}: {
- selectedBlur: number;
- onBlurSelect: (blurIntensity: number) => void;
-}) {
- const blurLevels = [
- { label: "Light", value: 4 },
- { label: "Medium", value: 8 },
- { label: "Heavy", value: 18 },
- ];
- const blurImage =
- "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
-
- return (
- <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
- {blurLevels.map((blur) => (
- <div
- key={blur.value}
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
- selectedBlur === blur.value && "border-2 border-primary"
- )}
- onClick={() => onBlurSelect(blur.value)}
- >
- <Image
- src={blurImage}
- alt={`Blur preview ${blur.label}`}
- fill
- className="object-cover"
- style={{ filter: `blur(${blur.value}px)` }}
- />
- <div className="absolute bottom-1 left-1 right-1 text-center">
- <span className="text-xs text-white bg-black/50 px-1 rounded">
- {blur.label}
- </span>
- </div>
- </div>
- ))}
- </div>
- );
-}
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-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index ee5dfc17..ad2a5b5a 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -1,10 +1,10 @@
"use client";
-import { Button } from "./ui/button";
+import { Button } from "../ui/button";
import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react";
-import { HeaderBase } from "./header-base";
+import { HeaderBase } from "../header-base";
import { useProjectStore } from "@/stores/project-store";
-import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
+import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
import { useState } from "react";
import {
DropdownMenu,
@@ -12,10 +12,10 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "./ui/dropdown-menu";
+} from "../ui/dropdown-menu";
import Link from "next/link";
-import { RenameProjectDialog } from "./rename-project-dialog";
-import { DeleteProjectDialog } from "./delete-project-dialog";
+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";
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/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx
index ac4b0f04..8ee60690 100644
--- a/apps/web/src/components/onboarding.tsx
+++ b/apps/web/src/components/editor/onboarding.tsx
@@ -1,7 +1,7 @@
"use client";
-import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
-import { Button } from "./ui/button";
+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";
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/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx
index c5351f76..af22a171 100644
--- a/apps/web/src/components/panel-preset-selector.tsx
+++ b/apps/web/src/components/editor/panel-preset-selector.tsx
@@ -1,13 +1,13 @@
"use client";
-import { Button } from "./ui/button";
+import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "./ui/dropdown-menu";
+} from "../ui/dropdown-menu";
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
@@ -51,7 +51,7 @@ export function PanelPresetSelector() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
- <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
+ <div className="px-3 py-2 text-xs font-medium text-muted-foreground">
Panel Presets
</div>
<DropdownMenuSeparator />
@@ -59,7 +59,7 @@ export function PanelPresetSelector() {
<DropdownMenuItem
key={preset}
onClick={() => handlePresetChange(preset)}
- className="flex items-start justify-between gap-2 py-2 cursor-pointer"
+ 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">
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
diff --git a/apps/web/src/components/export-button.tsx b/apps/web/src/components/export-button.tsx
deleted file mode 100644
index c325db5c..00000000
--- a/apps/web/src/components/export-button.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { TransitionUpIcon } from "./icons";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "./ui/dialog";
-import { Button } from "./ui/button";
-import { Input } from "./ui/input";
-import { useForm } from "react-hook-form";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "./ui/form";
-import {
- exportWaitlistSchema,
- type ExportWaitlistInput,
- type ExportWaitlistResponse,
-} from "@/lib/schemas/waitlist";
-import { cn } from "@/lib/utils";
-
-export function ExportButton() {
- const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
-
- const handleExport = () => {
- setIsExportDialogOpen(true);
- };
-
- return (
- <>
- <button
- type="button"
- className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] cursor-pointer hover:brightness-95 transition-all duration-200"
- onClick={handleExport}
- onKeyDown={(event) => {
- if (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 (soon)</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>
- <ExportDialog
- isOpen={isExportDialogOpen}
- onOpenChange={setIsExportDialogOpen}
- />
- </>
- );
-}
-
-function ExportDialog({
- isOpen,
- onOpenChange,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
-}) {
- const form = useForm<ExportWaitlistInput>({ defaultValues: { email: "" } });
- const { isSubmitting } = form.formState;
- const [serverMessage, setServerMessage] = useState<{
- text: string;
- type: "success" | "error";
- } | null>(null);
-
- const onSubmit = form.handleSubmit(async (values) => {
- const parsed = exportWaitlistSchema.safeParse(values);
- if (!parsed.success) {
- for (const issue of parsed.error.issues) {
- const fieldName =
- (issue.path[0] as keyof ExportWaitlistInput) || "email";
- form.setError(fieldName, { type: "zod", message: issue.message });
- }
- return;
- }
- setServerMessage(null);
- const { email } = parsed.data;
- const response = await fetch("/api/waitlist/export", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email }),
- });
- if (!response.ok) {
- setServerMessage({
- text: "Something went wrong. Please try again.",
- type: "error",
- });
- return;
- }
- const data: ExportWaitlistResponse = await response.json();
- if (data.success && data.alreadySubscribed) {
- setServerMessage({
- text: "You're already on the list.",
- type: "success",
- });
- return;
- }
- if (data.success) {
- setServerMessage({
- text: "You're on the list. We'll email you when it's ready.",
- type: "success",
- });
- form.reset();
- return;
- }
- setServerMessage({
- text: "Couldn't add your email. Please try again.",
- type: "error",
- });
- });
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="p-6">
- <DialogHeader>
- <DialogTitle>Export Project</DialogTitle>
- </DialogHeader>
- <div className="space-y-4">
- <DialogDescription>
- Export isn't ready yet. we're building a custom pipeline to make it
- great.
- </DialogDescription>
- <Form {...form}>
- <form className="flex flex-col gap-5" onSubmit={onSubmit}>
- <div className="flex flex-col gap-4 w-full">
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input
- type="email"
- placeholder="you@example.com"
- inputMode="email"
- autoComplete="email"
- disabled={isSubmitting}
- {...field}
- />
- </FormControl>
- <FormDescription>
- We'll let you know once export is ready.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting ? "Sending..." : "Notify me"}
- </Button>
- </div>
- {serverMessage ? (
- <p
- role="status"
- aria-live="polite"
- className={cn(
- "text-xs",
- serverMessage.type === "success" ? "text-green-600" : "text-red-600"
- )}
- >
- {serverMessage.text}
- </p>
- ) : null}
- </form>
- </Form>
- </div>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 4d68efb2..2bfc82b7 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -46,7 +46,7 @@ export function Header() {
return (
<div className="sticky top-4 z-50 mx-4 md:mx-0">
<HeaderBase
- className="bg-background border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ className="bg-background border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[11px]"
leftContent={leftContent}
rightContent={rightContent}
/>
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 5c629433..1b557412 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -12,7 +12,7 @@ import Link from "next/link";
export function Hero() {
return (
- <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
+ <div className="min-h-[calc(100svh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
<Image
className="absolute top-0 left-0 -z-50 size-full object-cover invert dark:invert-0 opacity-85"
src="/landing-page-dark.png"
diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index b8e9ae1d..b8e9ae1d 100644
--- a/apps/web/src/components/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx
index c50f9f71..dd8dfcc4 100644
--- a/apps/web/src/components/rename-project-dialog.tsx
+++ b/apps/web/src/components/rename-project-dialog.tsx
@@ -51,7 +51,7 @@ export function RenameProjectDialog({
}
}}
placeholder="Enter a new name"
- className="mt-4"
+ className="mt-4 bg-background border-2 border-border"
/>
<DialogFooter>
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
new file mode 100644
index 00000000..b94c1f98
--- /dev/null
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -0,0 +1,336 @@
+import * as React from "react";
+import { createPortal } from "react-dom";
+import { cn } from "../../lib/utils";
+import { Input } from "./input";
+
+interface ColorPickerProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ className?: string;
+ containerRef?: React.RefObject<HTMLDivElement>;
+}
+
+const hexToHsv = (hex: string) => {
+ const r = parseInt(hex.slice(0, 2), 16) / 255;
+ const g = parseInt(hex.slice(2, 4), 16) / 255;
+ const b = parseInt(hex.slice(4, 6), 16) / 255;
+
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const diff = max - min;
+
+ let h = 0;
+ let s = max === 0 ? 0 : diff / max;
+ let v = max;
+
+ if (diff !== 0) {
+ switch (max) {
+ case r:
+ h = ((g - b) / diff) % 6;
+ break;
+ case g:
+ h = (b - r) / diff + 2;
+ break;
+ case b:
+ h = (r - g) / diff + 4;
+ break;
+ }
+ }
+
+ h = (h * 60 + 360) % 360;
+ if (isNaN(h)) h = 0;
+
+ return [h, s, v];
+};
+
+const hsvToHex = (h: number, s: number, v: number) => {
+ const c = v * s;
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
+ const m = v - c;
+
+ let r = 0,
+ g = 0,
+ b = 0;
+
+ if (h >= 0 && h < 60) {
+ r = c;
+ g = x;
+ b = 0;
+ } else if (h >= 60 && h < 120) {
+ r = x;
+ g = c;
+ b = 0;
+ } else if (h >= 120 && h < 180) {
+ r = 0;
+ g = c;
+ b = x;
+ } else if (h >= 180 && h < 240) {
+ r = 0;
+ g = x;
+ b = c;
+ } else if (h >= 240 && h < 300) {
+ r = x;
+ g = 0;
+ b = c;
+ } else if (h >= 300 && h < 360) {
+ r = c;
+ g = 0;
+ b = x;
+ }
+
+ r = Math.round((r + m) * 255);
+ g = Math.round((g + m) * 255);
+ b = Math.round((b + m) * 255);
+
+ return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
+};
+
+const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
+ ({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [isDragging, setIsDragging] = React.useState<
+ "saturation" | "hue" | null
+ >(null);
+ const [pickerPosition, setPickerPosition] = React.useState({
+ right: 0,
+ bottom: 0,
+ });
+ const [internalHue, setInternalHue] = React.useState(0);
+ const [inputValue, setInputValue] = React.useState(value);
+
+ const pickerRef = React.useRef<HTMLDivElement>(null);
+ const saturationRef = React.useRef<HTMLDivElement>(null);
+ const hueRef = React.useRef<HTMLDivElement>(null);
+ const triggerRef = React.useRef<HTMLDivElement>(null);
+
+ const [h, s, v] = hexToHsv(value);
+ const displayHue = s > 0 ? h : internalHue;
+
+ React.useEffect(() => {
+ setInputValue(value);
+ }, [value]);
+
+ React.useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ pickerRef.current &&
+ !pickerRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () =>
+ document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen]);
+
+ React.useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDragging) return;
+
+ if (isDragging === "saturation" && saturationRef.current) {
+ const rect = saturationRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width)
+ );
+ const y = Math.max(
+ 0,
+ Math.min(1, (e.clientY - rect.top) / rect.height)
+ );
+ const newS = x;
+ const newV = 1 - y;
+ const newHex = hsvToHex(displayHue, newS, newV);
+ onChange?.(newHex);
+ }
+
+ if (isDragging === "hue" && hueRef.current) {
+ const rect = hueRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width)
+ );
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = hsvToHex(newH, s, v);
+ onChange?.(newHex);
+ }
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(null);
+ };
+
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }
+ }, [isDragging, displayHue, s, v, onChange]);
+
+ const handleSaturationMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setIsDragging("saturation");
+ const rect = saturationRef.current!.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
+ const newS = x;
+ const newV = 1 - y;
+ const newHex = hsvToHex(displayHue, newS, newV);
+ onChange?.(newHex);
+ };
+
+ const handleHueMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setIsDragging("hue");
+ const rect = hueRef.current!.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = hsvToHex(newH, s, v);
+ onChange?.(newHex);
+ }
+ };
+
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const hex = e.target.value.replace("#", "");
+ setInputValue(hex);
+ };
+
+ const handleInputBlur = () => {
+ onChange?.(inputValue);
+ };
+
+ const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter") {
+ onChange?.(inputValue);
+ e.currentTarget.blur();
+ }
+ };
+
+ const saturationStyle = {
+ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
+ };
+
+ const hueStyle = {
+ background:
+ "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
+ };
+
+ return (
+ <div className="relative">
+ <div
+ ref={ref}
+ className={cn(
+ "bg-panel-accent h-9 rounded-full flex items-center gap-2 px-[0.45rem]",
+ className
+ )}
+ {...props}
+ >
+ <div
+ ref={triggerRef}
+ className="size-6 rounded-full cursor-pointer hover:ring-2 hover:ring-white/20 transition-all"
+ style={{ backgroundColor: `#${value}` }}
+ onClick={() => {
+ if (!isOpen && triggerRef.current && containerRef?.current) {
+ const containerRect =
+ containerRef.current.getBoundingClientRect();
+ setPickerPosition({
+ right: window.innerWidth - containerRect.left - 8,
+ bottom: window.innerHeight - containerRect.bottom,
+ });
+ }
+ setIsOpen(!isOpen);
+ }}
+ />
+ <div className="flex-1 flex items-center">
+ <Input
+ className="bg-transparent p-0 !ring-0 !ring-offset-0 !border-0"
+ containerClassName="w-full"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ />
+ </div>
+ </div>
+
+ {isOpen &&
+ createPortal(
+ <div
+ ref={pickerRef}
+ className="fixed z-50 p-4 bg-popover border border-border rounded-lg shadow-lg select-none"
+ style={{
+ right: pickerPosition.right,
+ bottom: pickerPosition.bottom,
+ }}
+ >
+ <div
+ ref={saturationRef}
+ className="relative w-48 h-32 cursor-crosshair mb-3"
+ style={saturationStyle}
+ onMouseDown={handleSaturationMouseDown}
+ >
+ <ColorCircle
+ size="sm"
+ position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
+ color={`#${value}`}
+ />
+ </div>
+
+ <div
+ ref={hueRef}
+ className="relative w-48 h-4 rounded-lg cursor-pointer"
+ style={hueStyle}
+ onMouseDown={handleHueMouseDown}
+ >
+ <ColorCircle
+ size="md"
+ position={{
+ left: `${(displayHue / 360) * 100}%`,
+ top: "50%",
+ }}
+ color={`#${value}`}
+ />
+ </div>
+ </div>,
+ document.body
+ )}
+ </div>
+ );
+ }
+);
+ColorPicker.displayName = "ColorPicker";
+
+const ColorCircle = ({
+ size,
+ position,
+ color,
+}: {
+ size: "sm" | "md";
+ position: { left: string; top: string };
+ color: string;
+}) => (
+ <div
+ className={`absolute border-3 border-white rounded-full shadow-lg pointer-events-none ${
+ size === "sm" ? "w-3 h-3" : "w-4 h-4"
+ }`}
+ style={{
+ left: position.left,
+ top: position.top,
+ transform: "translate(-50%, -50%)",
+ backgroundColor: color,
+ }}
+ />
+);
+
+export { ColorPicker };
diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx
index 9e29c83b..c238b33c 100644
--- a/apps/web/src/components/ui/draggable-item.tsx
+++ b/apps/web/src/components/ui/draggable-item.tsx
@@ -12,11 +12,12 @@ 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";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
- dragData: Record<string, any>;
+ dragData: DragData;
onDragStart?: (e: React.DragEvent) => void;
onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number;
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 479a5f0c..7bb11274 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -20,7 +20,7 @@ export function FontPicker({
}: FontPickerProps) {
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
- <SelectTrigger className={`w-full text-xs ${className || ""}`}>
+ <SelectTrigger className={`w-full bg-panel-accent h-8 text-xs ${className || ""}`}>
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx
index 283ba17a..6b785a58 100644
--- a/apps/web/src/components/ui/progress.tsx
+++ b/apps/web/src/components/ui/progress.tsx
@@ -12,13 +12,13 @@ const Progress = React.forwardRef<
<ProgressPrimitive.Root
ref={ref}
className={cn(
- "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
+ "relative h-2 w-full overflow-hidden rounded-full bg-accent",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
- className="h-full w-full flex-1 bg-primary transition-all"
+ className="h-full w-full flex-1 bg-foreground transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index 549cf841..13694b43 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -12,6 +12,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
+ position="top-center"
+ offset={20}
toastOptions={{
classNames: {
toast:
@@ -23,6 +25,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
+ expand={false}
+ richColors
{...props}
/>
);
diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx
index b9004a34..d9ae9ea6 100644
--- a/apps/web/src/components/ui/textarea.tsx
+++ b/apps/web/src/components/ui/textarea.tsx
@@ -9,7 +9,9 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
- "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[60px] w-full rounded-md border bg-accent/50 px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
ref={ref}
diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts
index 4278ce7e..50e8ae5d 100644
--- a/apps/web/src/constants/actions.ts
+++ b/apps/web/src/constants/actions.ts
@@ -45,7 +45,9 @@ export type Action =
| "duplicate-selected" // Duplicate selected element
| "toggle-snapping" // Toggle snapping
| "undo" // Undo last action
- | "redo"; // Redo last undone action
+ | "redo" // Redo last undone action
+ | "copy-selected" // Copy selected elements to clipboard
+ | "paste-selected"; // Paste elements from clipboard at playhead
/**
* Defines the arguments, if present for a given type that is required to be passed on
diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts
new file mode 100644
index 00000000..e3fcce38
--- /dev/null
+++ b/apps/web/src/constants/text-constants.ts
@@ -0,0 +1,27 @@
+import { TextElement } from "@/types/timeline";
+import { TIMELINE_CONSTANTS } from "./timeline-constants";
+
+export const DEFAULT_TEXT_ELEMENT: Omit<
+ TextElement,
+ "id"
+> = {
+ type: "text",
+ name: "Text",
+ content: "Default Text",
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+};
diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts
index c3fe20c6..b721b74e 100644
--- a/apps/web/src/hooks/use-aspect-ratio.ts
+++ b/apps/web/src/hooks/use-aspect-ratio.ts
@@ -6,7 +6,7 @@ import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store";
export function useAspectRatio() {
const { canvasPresets } = useEditorStore();
const { activeProject } = useProjectStore();
- const { mediaItems } = useMediaStore();
+ const { mediaFiles } = useMediaStore();
const { tracks } = useTimelineStore();
const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
@@ -24,14 +24,14 @@ export function useAspectRatio() {
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
- const mediaItem = mediaItems.find(
- (item) => item.id === element.mediaId
+ const mediaFile = mediaFiles.find(
+ (file) => file.id === element.mediaId
);
if (
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image")
+ mediaFile &&
+ (mediaFile.type === "video" || mediaFile.type === "image")
) {
- return getMediaAspectRatio(mediaItem);
+ return getMediaAspectRatio(mediaFile);
}
}
}
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
index 81500416..974f9db5 100644
--- a/apps/web/src/hooks/use-editor-actions.ts
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -203,6 +203,23 @@ export function useEditorActions() {
);
useActionHandler(
+ "copy-selected",
+ () => {
+ if (selectedElements.length === 0) return;
+ useTimelineStore.getState().copySelected();
+ },
+ undefined
+ );
+
+ useActionHandler(
+ "paste-selected",
+ () => {
+ useTimelineStore.getState().pasteAtTime(currentTime);
+ },
+ undefined
+ );
+
+ useActionHandler(
"toggle-snapping",
() => {
toggleSnapping();
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
index e2cde3b4..29e3c09b 100644
--- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -63,6 +63,14 @@ const actionDescriptions: Record<
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
undo: { description: "Undo", category: "History" },
redo: { description: "Redo", category: "History" },
+ "copy-selected": {
+ description: "Copy selected elements",
+ category: "Editing",
+ },
+ "paste-selected": {
+ description: "Paste elements at playhead",
+ category: "Editing",
+ },
};
// Convert key binding format to display format
diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts
index 46e77fa9..87026500 100644
--- a/apps/web/src/hooks/use-selection-box.ts
+++ b/apps/web/src/hooks/use-selection-box.ts
@@ -190,6 +190,22 @@ export function useSelectionBox({
};
}, [selectionBox, selectElementsInBox]);
+ useEffect(() => {
+ if (!selectionBox?.isActive) return;
+
+ const previousBodyUserSelect = document.body.style.userSelect;
+ const container = containerRef.current;
+ const previousContainerUserSelect = container?.style.userSelect ?? "";
+
+ document.body.style.userSelect = "none";
+ if (container) container.style.userSelect = "none";
+
+ return () => {
+ document.body.style.userSelect = previousBodyUserSelect;
+ if (container) container.style.userSelect = previousContainerUserSelect;
+ };
+ }, [selectionBox?.isActive, containerRef]);
+
return {
selectionBox,
handleMouseDown,
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts
index 2875185c..4812459a 100644
--- a/apps/web/src/hooks/use-timeline-element-resize.ts
+++ b/apps/web/src/hooks/use-timeline-element-resize.ts
@@ -9,28 +9,15 @@ interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
- onUpdateTrim: (
- trackId: string,
- elementId: string,
- trimStart: number,
- trimEnd: number
- ) => void;
- onUpdateDuration: (
- trackId: string,
- elementId: string,
- duration: number
- ) => void;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
- onUpdateTrim,
- onUpdateDuration,
}: UseTimelineElementResizeProps) {
const [resizing, setResizing] = useState<ResizeState | null>(null);
- const { mediaItems } = useMediaStore();
+ const { mediaFiles } = useMediaStore();
const {
updateElementStartTime,
updateElementTrim,
@@ -88,11 +75,11 @@ export function useTimelineElementResize({
// Media elements - check the media type
if (element.type === "media") {
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem) return false;
+ const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
+ if (!mediaFile) return false;
// Images can be extended (static content)
- if (mediaItem.type === "image") {
+ if (mediaFile.type === "image") {
return true;
}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
new file mode 100644
index 00000000..f25f5f6b
--- /dev/null
+++ b/apps/web/src/lib/export.ts
@@ -0,0 +1,296 @@
+import {
+ Output,
+ Mp4OutputFormat,
+ WebMOutputFormat,
+ BufferTarget,
+ CanvasSource,
+ AudioBufferSource,
+ QUALITY_LOW,
+ QUALITY_MEDIUM,
+ QUALITY_HIGH,
+ QUALITY_VERY_HIGH,
+} from "mediabunny";
+import { renderTimelineFrame } from "./timeline-renderer";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { useProjectStore } from "@/stores/project-store";
+import { DEFAULT_FPS } from "@/stores/project-store";
+import { ExportOptions, ExportResult } from "@/types/export";
+
+export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
+ format: "mp4",
+ quality: "high",
+ includeAudio: true,
+};
+
+const qualityMap = {
+ low: QUALITY_LOW,
+ medium: QUALITY_MEDIUM,
+ high: QUALITY_HIGH,
+ very_high: QUALITY_VERY_HIGH,
+};
+
+interface AudioElement {
+ buffer: AudioBuffer;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ muted: boolean;
+}
+
+async function createTimelineAudioBuffer(
+ tracks: any[],
+ mediaFiles: any[],
+ 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 mediaMap = new Map(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;
+
+ const mediaItem = mediaMap.get(element.mediaId);
+ if (!mediaItem || mediaItem.type !== "audio") continue;
+
+ const visibleDuration =
+ 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)
+ );
+
+ audioElements.push({
+ buffer: audioBuffer,
+ 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);
+ }
+ }
+ }
+
+ if (audioElements.length === 0) {
+ return null; // No audio to mix
+ }
+
+ // Create output buffer
+ const outputChannels = 2; // Stereo
+ const outputLength = Math.ceil(duration * sampleRate);
+ const outputBuffer = audioContext.createBuffer(
+ outputChannels,
+ outputLength,
+ 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];
+ }
+ }
+ }
+
+ return outputBuffer;
+}
+
+export async function exportProject(
+ options: ExportOptions
+): Promise<ExportResult> {
+ const { format, quality, fps, includeAudio, onProgress, onCancel } = options;
+
+ try {
+ const timelineStore = useTimelineStore.getState();
+ const mediaStore = useMediaStore.getState();
+ const projectStore = useProjectStore.getState();
+
+ const { tracks, getTotalDuration } = timelineStore;
+ const { mediaFiles } = mediaStore;
+ const { activeProject } = projectStore;
+
+ if (!activeProject) {
+ return { success: false, error: "No active project" };
+ }
+
+ const duration = getTotalDuration();
+ if (duration === 0) {
+ return { success: false, error: "Project is empty" };
+ }
+
+ const exportFps = fps || activeProject.fps || DEFAULT_FPS;
+ const canvasSize = activeProject.canvasSize;
+
+ const outputFormat =
+ format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
+
+ // BufferTarget for smaller files, StreamTarget for larger ones
+ // TODO: Implement StreamTarget
+ const output = new Output({
+ format: outputFormat,
+ target: new BufferTarget(),
+ });
+
+ // Canvas for rendering
+ const canvas = document.createElement("canvas");
+ canvas.width = canvasSize.width;
+ canvas.height = canvasSize.height;
+ const ctx = canvas.getContext("2d");
+
+ if (!ctx) {
+ return { success: false, error: "Failed to create canvas context" };
+ }
+
+ const videoSource = new CanvasSource(canvas, {
+ codec: format === "webm" ? "vp9" : "avc", // VP9 for WebM, H.264 for MP4
+ bitrate: qualityMap[quality],
+ });
+
+ output.addVideoTrack(videoSource, { frameRate: exportFps });
+
+ // Add audio track if requested (but don't add data yet)
+ let audioSource: AudioBufferSource | null = null;
+ let audioBuffer: AudioBuffer | null = null;
+
+ if (includeAudio) {
+ onProgress?.(0.05); // 5% for audio processing
+
+ audioBuffer = await createTimelineAudioBuffer(
+ tracks,
+ mediaFiles,
+ duration
+ );
+
+ if (audioBuffer) {
+ audioSource = new AudioBufferSource({
+ codec: format === "webm" ? "opus" : "aac", // Opus for WebM, AAC for MP4
+ bitrate: qualityMap[quality], // Use same quality for audio
+ });
+
+ output.addAudioTrack(audioSource);
+ }
+ }
+
+ // Start the output (after all tracks are added)
+ await output.start();
+
+ // Now add audio data after starting
+ if (audioSource && audioBuffer) {
+ await audioSource.add(audioBuffer);
+ audioSource.close();
+ }
+
+ const totalFrames = Math.ceil(duration * exportFps);
+ let cancelled = false;
+
+ // Render each frame
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
+ // Check for cancellation
+ if (onCancel?.()) {
+ cancelled = true;
+ break;
+ }
+
+ const time = frameIndex / exportFps;
+
+ await renderTimelineFrame({
+ ctx,
+ time,
+ canvasWidth: canvas.width,
+ canvasHeight: canvas.height,
+ tracks,
+ mediaFiles,
+ backgroundColor:
+ activeProject.backgroundType === "blur"
+ ? "transparent"
+ : activeProject.backgroundColor || "#000000",
+ projectCanvasSize: canvasSize,
+ });
+
+ const frameDuration = 1 / exportFps;
+ await videoSource.add(time, frameDuration);
+
+ // Adjust progress to account for audio processing (5% at start)
+ const videoProgress = includeAudio
+ ? 0.05 + (frameIndex / totalFrames) * 0.95
+ : frameIndex / totalFrames;
+ onProgress?.(videoProgress);
+ }
+
+ if (cancelled) {
+ await output.cancel();
+ return { success: false, cancelled: true };
+ }
+ videoSource.close();
+ await output.finalize();
+ onProgress?.(1);
+
+ return {
+ success: true,
+ buffer: output.target.buffer || undefined,
+ };
+ } catch (error) {
+ console.error("Export failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown export error",
+ };
+ }
+}
+
+export function getExportMimeType(format: "mp4" | "webm"): string {
+ return format === "webm" ? "video/webm" : "video/mp4";
+}
+
+export function getExportFileExtension(format: "mp4" | "webm"): string {
+ return `.${format}`;
+}
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
index 353c3113..7a31466f 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -1,14 +1,13 @@
import { toast } from "sonner";
import {
getFileType,
- generateVideoThumbnail,
getMediaDuration,
getImageDimensions,
- type MediaItem,
} from "@/stores/media-store";
-import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils";
+import { MediaFile } from "@/types/media";
+import { generateThumbnail, getVideoInfo } from "./mediabunny-utils";
-export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
+export interface ProcessedMediaItem extends Omit<MediaFile, "id"> {}
export async function processMediaFiles(
files: FileList | File[],
@@ -37,33 +36,23 @@ export async function processMediaFiles(
try {
if (fileType === "image") {
- // Get image dimensions
const dimensions = await getImageDimensions(file);
width = dimensions.width;
height = dimensions.height;
} else if (fileType === "video") {
try {
- // Use FFmpeg for comprehensive video info extraction
- const videoInfo = await getVideoInfo(file);
+ const videoInfo = await getVideoInfo({ videoFile: file });
duration = videoInfo.duration;
width = videoInfo.width;
height = videoInfo.height;
fps = videoInfo.fps;
- // Generate thumbnail using FFmpeg
- thumbnailUrl = await generateThumbnail(file, 1);
+ thumbnailUrl = await generateThumbnail({
+ videoFile: file,
+ timeInSeconds: 1,
+ });
} catch (error) {
- console.warn(
- "FFmpeg processing failed, falling back to basic processing:",
- error
- );
- // Fallback to basic processing
- const videoResult = await generateVideoThumbnail(file);
- thumbnailUrl = videoResult.thumbnailUrl;
- width = videoResult.width;
- height = videoResult.height;
- duration = await getMediaDuration(file);
- // FPS will remain undefined for fallback
+ console.warn("Video processing failed", error);
}
} else if (fileType === "audio") {
// For audio, we don't set width/height/fps (they'll be undefined)
@@ -82,7 +71,6 @@ export async function processMediaFiles(
fps,
});
- // Yield back to the event loop to keep the UI responsive
await new Promise((resolve) => setTimeout(resolve, 0));
completed += 1;
diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/mediabunny-utils.ts
index 0643ecb9..fcb3da0f 100644
--- a/apps/web/src/lib/ffmpeg-utils.ts
+++ b/apps/web/src/lib/mediabunny-utils.ts
@@ -1,7 +1,7 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
-import { toBlobURL } from "@ffmpeg/util";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
+import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
let ffmpeg: FFmpeg | null = null;
@@ -14,256 +14,99 @@ export const initFFmpeg = async (): Promise<FFmpeg> => {
return ffmpeg;
};
-export const generateThumbnail = async (
- videoFile: File,
- timeInSeconds = 1
-): Promise<string> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = "thumbnail.jpg";
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Generate thumbnail at specific time
- await ffmpeg.exec([
- "-i",
- inputName,
- "-ss",
- timeInSeconds.toString(),
- "-vframes",
- "1",
- "-vf",
- "scale=320:240",
- "-q:v",
- "2",
- outputName,
- ]);
+export async function generateThumbnail({
+ videoFile,
+ timeInSeconds,
+}: {
+ videoFile: File;
+ timeInSeconds: number;
+}): Promise<string> {
+ const input = new Input({
+ source: new BlobSource(videoFile),
+ formats: ALL_FORMATS,
+ });
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "image/jpeg" });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack) {
+ throw new Error("No video track found in the file");
+ }
- return URL.createObjectURL(blob);
-};
+ // Check if we can decode this video
+ const canDecode = await videoTrack.canDecode();
+ if (!canDecode) {
+ throw new Error("Video codec not supported for decoding");
+ }
-export const trimVideo = async (
- videoFile: File,
- startTime: number,
- endTime: number,
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
+ const sink = new VideoSampleSink(videoTrack);
- const inputName = "input.mp4";
- const outputName = "output.mp4";
+ const frame = await sink.getSample(timeInSeconds);
- // Set up progress callback
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
+ if (!frame) {
+ throw new Error("Could not get frame at specified time");
}
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- const duration = endTime - startTime;
+ const canvas = document.createElement("canvas");
+ canvas.width = 320;
+ canvas.height = 240;
+ const ctx = canvas.getContext("2d");
- // Trim video
- await ffmpeg.exec([
- "-i",
- inputName,
- "-ss",
- startTime.toString(),
- "-t",
- duration.toString(),
- "-c",
- "copy", // Use stream copy for faster processing
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "video/mp4" });
+ if (!ctx) {
+ throw new Error("Could not get canvas context");
+ }
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
+ frame.draw(ctx, 0, 0, 320, 240);
- return blob;
-};
+ 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
+ );
+ });
+}
-export const getVideoInfo = async (
- videoFile: File
-): Promise<{
+export async function getVideoInfo({
+ videoFile,
+}: {
+ videoFile: File;
+}): Promise<{
duration: number;
width: number;
height: number;
fps: number;
-}> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Capture FFmpeg stderr output with a one-time listener pattern
- let ffmpegOutput = "";
- let listening = true;
- const listener = (data: string) => {
- if (listening) ffmpegOutput += data;
- };
- ffmpeg.on("log", ({ message }) => listener(message));
-
- // Run ffmpeg to get info (stderr will contain the info)
- try {
- await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]);
- } catch (error) {
- listening = false;
- await ffmpeg.deleteFile(inputName);
- console.error("FFmpeg execution failed:", error);
- throw new Error(
- "Failed to extract video info. The file may be corrupted or in an unsupported format."
- );
- }
+}> {
+ const input = new Input({
+ source: new BlobSource(videoFile),
+ formats: ALL_FORMATS,
+ });
- // Disable listener after exec completes
- listening = false;
+ const duration = await input.computeDuration();
+ const videoTrack = await input.getPrimaryVideoTrack();
- // Cleanup
- await ffmpeg.deleteFile(inputName);
-
- // Parse output for duration, resolution, and fps
- // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s
- // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc
-
- const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/);
- let duration = 0;
- if (durationMatch) {
- const [, h, m, s] = durationMatch;
- duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
+ if (!videoTrack) {
+ throw new Error("No video track found in the file");
}
- const videoStreamMatch = ffmpegOutput.match(
- /Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/
- );
- let width = 0,
- height = 0,
- fps = 0;
- if (videoStreamMatch) {
- width = parseInt(videoStreamMatch[1]);
- height = parseInt(videoStreamMatch[2]);
- fps = parseFloat(videoStreamMatch[3]);
- }
+ // Get frame rate from packet statistics
+ const packetStats = await videoTrack.computePacketStats(100);
+ const fps = packetStats.averagePacketRate;
return {
duration,
- width,
- height,
+ width: videoTrack.displayWidth,
+ height: videoTrack.displayHeight,
fps,
};
-};
-
-export const convertToWebM = async (
- videoFile: File,
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = "output.webm";
-
- // Set up progress callback
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
- }
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Convert to WebM
- await ffmpeg.exec([
- "-i",
- inputName,
- "-c:v",
- "libvpx-vp9",
- "-crf",
- "30",
- "-b:v",
- "0",
- "-c:a",
- "libopus",
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "video/webm" });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return blob;
-};
-
-export const extractAudio = async (
- videoFile: File,
- format: "mp3" | "wav" = "mp3"
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = `output.${format}`;
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Extract audio
- await ffmpeg.exec([
- "-i",
- inputName,
- "-vn", // Disable video
- "-acodec",
- format === "mp3" ? "libmp3lame" : "pcm_s16le",
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: `audio/${format}` });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return blob;
-};
+}
+// Audio mixing for timeline - keeping FFmpeg for now due to complexity
+// TODO: Replace with Mediabunny audio processing when implementing canvas preview
export const extractTimelineAudio = async (
onProgress?: (progress: number) => void
): Promise<Blob> => {
@@ -308,14 +151,14 @@ export const extractTimelineAudio = async (
for (const element of track.elements) {
if (element.type === "media") {
- const mediaItem = mediaStore.mediaItems.find(
+ const mediaFile = mediaStore.mediaFiles.find(
(m) => m.id === element.mediaId
);
- if (!mediaItem) continue;
+ if (!mediaFile) continue;
- if (mediaItem.type === "video" || mediaItem.type === "audio") {
+ if (mediaFile.type === "video" || mediaFile.type === "audio") {
audioElements.push({
- file: mediaItem.file,
+ file: mediaFile.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index b3376a5f..3aadf883 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -1,5 +1,5 @@
import { TProject } from "@/types/project";
-import { MediaItem } from "@/stores/media-store";
+import { MediaFile } from "@/types/media";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import {
@@ -124,8 +124,8 @@ class StorageService {
await this.projectsAdapter.remove(id);
}
- // Media operations - now project-specific
- async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
+ // Media operations
+ async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
@@ -148,10 +148,10 @@ class StorageService {
await mediaMetadataAdapter.set(mediaItem.id, metadata);
}
- async loadMediaItem(
+ async loadMediaFile(
projectId: string,
id: string
- ): Promise<MediaItem | null> {
+ ): Promise<MediaFile | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
@@ -192,14 +192,14 @@ class StorageService {
};
}
- async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
+ async loadAllMediaFiles(projectId: string): Promise<MediaFile[]> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const mediaIds = await mediaMetadataAdapter.list();
- const mediaItems: MediaItem[] = [];
+ const mediaItems: MediaFile[] = [];
for (const id of mediaIds) {
- const item = await this.loadMediaItem(projectId, id);
+ const item = await this.loadMediaFile(projectId, id);
if (item) {
mediaItems.push(item);
}
@@ -208,7 +208,7 @@ class StorageService {
return mediaItems;
}
- async deleteMediaItem(projectId: string, id: string): Promise<void> {
+ async deleteMediaFile(projectId: string, id: string): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts
new file mode 100644
index 00000000..6241a7d9
--- /dev/null
+++ b/apps/web/src/lib/timeline-renderer.ts
@@ -0,0 +1,163 @@
+import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
+import type { TimelineTrack } from "@/types/timeline";
+import type { MediaFile } from "@/types/media";
+
+export interface RenderContext {
+ ctx: CanvasRenderingContext2D;
+ time: number;
+ canvasWidth: number;
+ canvasHeight: number;
+ tracks: TimelineTrack[];
+ mediaFiles: MediaFile[];
+ backgroundColor?: string;
+ projectCanvasSize?: { width: number; height: number };
+}
+
+export async function renderTimelineFrame({
+ ctx,
+ time,
+ canvasWidth,
+ canvasHeight,
+ tracks,
+ mediaFiles,
+ backgroundColor,
+ projectCanvasSize,
+}: RenderContext): Promise<void> {
+ // Background
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+ if (backgroundColor && backgroundColor !== "transparent") {
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ }
+
+ const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
+ const scaleY = projectCanvasSize
+ ? canvasHeight / projectCanvasSize.height
+ : 1;
+ const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const));
+ const active: Array<{
+ track: TimelineTrack;
+ element: TimelineTrack["elements"][number];
+ mediaItem: MediaFile | null;
+ }> = [];
+
+ for (let t = tracks.length - 1; t >= 0; t -= 1) {
+ const track = tracks[t];
+ for (const element of track.elements) {
+ if ((element as any).hidden) continue;
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (time >= elementStart && time < elementEnd) {
+ let mediaItem: MediaFile | null = null;
+ if (element.type === "media") {
+ mediaItem =
+ element.mediaId === "test"
+ ? null
+ : idToMedia.get(element.mediaId) || null;
+ }
+ active.push({ track, element, mediaItem });
+ }
+ }
+ }
+
+ for (const { element, mediaItem } of active) {
+ if (element.type === "media" && mediaItem) {
+ if (mediaItem.type === "video") {
+ const input = new Input({
+ source: new BlobSource(mediaItem.file),
+ formats: ALL_FORMATS,
+ });
+ const track = await input.getPrimaryVideoTrack();
+ if (!track) continue;
+ const decodable = await track.canDecode();
+ if (!decodable) continue;
+ const sink = new VideoSampleSink(track);
+
+ const localTime = time - element.startTime + element.trimStart;
+ const sample = await sink.getSample(localTime);
+ if (!sample) continue;
+
+ const mediaW = Math.max(1, mediaItem.width || canvasWidth);
+ const mediaH = Math.max(1, mediaItem.height || canvasHeight);
+ const containScale = Math.min(
+ canvasWidth / mediaW,
+ canvasHeight / mediaH
+ );
+ const drawW = mediaW * containScale;
+ const drawH = mediaH * containScale;
+ const drawX = (canvasWidth - drawW) / 2;
+ const drawY = (canvasHeight - drawH) / 2;
+ sample.draw(ctx, drawX, drawY, drawW, drawH);
+ }
+ if (mediaItem.type === "image") {
+ const img = new Image();
+ await new Promise<void>((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject(new Error("Image load failed"));
+ img.src = mediaItem.url || URL.createObjectURL(mediaItem.file);
+ });
+ const mediaW = Math.max(
+ 1,
+ mediaItem.width || img.naturalWidth || canvasWidth
+ );
+ const mediaH = Math.max(
+ 1,
+ mediaItem.height || img.naturalHeight || canvasHeight
+ );
+ const containScale = Math.min(
+ canvasWidth / mediaW,
+ canvasHeight / mediaH
+ );
+ const drawW = mediaW * containScale;
+ const drawH = mediaH * containScale;
+ const drawX = (canvasWidth - drawW) / 2;
+ const drawY = (canvasHeight - drawH) / 2;
+ ctx.drawImage(img, drawX, drawY, drawW, drawH);
+ }
+ }
+ if (element.type === "text") {
+ const posX = canvasWidth / 2 + (element as any).x * scaleX;
+ const posY = canvasHeight / 2 + (element as any).y * scaleY;
+ ctx.save();
+ ctx.translate(posX, posY);
+ ctx.rotate(((element as any).rotation * Math.PI) / 180);
+ ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity));
+ const px = (element as any).fontSize * scaleX;
+ const weight = (element as any).fontWeight === "bold" ? "bold " : "";
+ const style = (element as any).fontStyle === "italic" ? "italic " : "";
+ ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`;
+ ctx.fillStyle = (element as any).color;
+ ctx.textAlign = (element as any).textAlign;
+ ctx.textBaseline = "middle";
+ const metrics = ctx.measureText((element as any).content);
+ const ascent =
+ (metrics as unknown as { actualBoundingBoxAscent?: number })
+ .actualBoundingBoxAscent ?? px * 0.8;
+ const descent =
+ (metrics as unknown as { actualBoundingBoxDescent?: number })
+ .actualBoundingBoxDescent ?? px * 0.2;
+ const textW = metrics.width;
+ const textH = ascent + descent;
+ const padX = 8 * scaleX;
+ const padY = 4 * scaleX;
+ if ((element as any).backgroundColor) {
+ ctx.save();
+ ctx.fillStyle = (element as any).backgroundColor;
+ let bgLeft = -textW / 2;
+ if (ctx.textAlign === "left") bgLeft = 0;
+ if (ctx.textAlign === "right") bgLeft = -textW;
+ ctx.fillRect(
+ bgLeft - padX,
+ -textH / 2 - padY,
+ textW + padX * 2,
+ textH + padY * 2
+ );
+ ctx.restore();
+ }
+ ctx.fillText((element as any).content, 0, 0);
+ ctx.restore();
+ }
+ }
+}
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index 8d2ee03a..42159b2e 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -7,6 +7,10 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+export function uppercase(str: string) {
+ return str.toUpperCase();
+}
+
/**
* Generates a UUID v4 string
* Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
index 4c11cfde..7fa0da5e 100644
--- a/apps/web/src/stores/keybindings-store.ts
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -23,6 +23,8 @@ export const defaultKeybindings: KeybindingConfig = {
n: "toggle-snapping",
"ctrl+a": "select-all",
"ctrl+d": "duplicate-selected",
+ "ctrl+c": "copy-selected",
+ "ctrl+v": "paste-selected",
"ctrl+z": "undo",
"ctrl+shift+z": "redo",
"ctrl+y": "redo",
@@ -162,7 +164,7 @@ export const useKeybindingsStore = create<KeybindingsState>()(
}),
{
name: "opencut-keybindings",
- version: 1,
+ version: 2,
}
)
);
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 95676d75..2b7cb62e 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -2,44 +2,21 @@ import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
-
-export type MediaType = "image" | "video" | "audio";
-
-export interface MediaItem {
- 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;
- // Text-specific properties
- content?: string; // Text content
- fontSize?: number; // Font size
- fontFamily?: string; // Font family
- color?: string; // Text color
- backgroundColor?: string; // Background color
- textAlign?: "left" | "center" | "right"; // Text alignment
-}
+import { MediaType, MediaFile } from "@/types/media";
interface MediaStore {
- mediaItems: MediaItem[];
+ mediaFiles: MediaFile[];
isLoading: boolean;
- // Actions - now require projectId
- addMediaItem: (
+ // Actions
+ addMediaFile: (
projectId: string,
- item: Omit<MediaItem, "id">
+ file: Omit<MediaFile, "id">
) => Promise<void>;
- removeMediaItem: (projectId: string, id: string) => Promise<void>;
+ removeMediaFile: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>;
clearProjectMedia: (projectId: string) => Promise<void>;
- clearAllMedia: () => void; // Clear local state only
+ clearAllMedia: () => void;
}
// Helper function to determine file type
@@ -150,8 +127,7 @@ export const getMediaDuration = (file: File): Promise<number> => {
});
};
-// Helper to get aspect ratio from MediaItem
-export const getMediaAspectRatio = (item: MediaItem): number => {
+export const getMediaAspectRatio = (item: MediaFile): number => {
if (item.width && item.height) {
return item.width / item.height;
}
@@ -159,35 +135,35 @@ export const getMediaAspectRatio = (item: MediaItem): number => {
};
export const useMediaStore = create<MediaStore>((set, get) => ({
- mediaItems: [],
+ mediaFiles: [],
isLoading: false,
- addMediaItem: async (projectId, item) => {
- const newItem: MediaItem = {
- ...item,
+ addMediaFile: async (projectId, file) => {
+ const newItem: MediaFile = {
+ ...file,
id: generateUUID(),
};
// Add to local state immediately for UI responsiveness
set((state) => ({
- mediaItems: [...state.mediaItems, newItem],
+ mediaFiles: [...state.mediaFiles, newItem],
}));
// Save to persistent storage in background
try {
- await storageService.saveMediaItem(projectId, newItem);
+ await storageService.saveMediaFile(projectId, newItem);
} catch (error) {
console.error("Failed to save media item:", error);
// Remove from local state if save failed
set((state) => ({
- mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id),
+ mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id),
}));
}
},
- removeMediaItem: async (projectId: string, id: string) => {
+ removeMediaFile: async (projectId: string, id: string) => {
const state = get();
- const item = state.mediaItems.find((media) => media.id === id);
+ const item = state.mediaFiles.find((media) => media.id === id);
// Cleanup object URLs to prevent memory leaks
if (item?.url) {
@@ -199,7 +175,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// 1) Remove from local state immediately
set((state) => ({
- mediaItems: state.mediaItems.filter((media) => media.id !== id),
+ mediaFiles: state.mediaFiles.filter((media) => media.id !== id),
}));
// 2) Cascade into the timeline: remove any elements using this media ID
@@ -238,7 +214,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// 3) Remove from persistent storage
try {
- await storageService.deleteMediaItem(projectId, id);
+ await storageService.deleteMediaFile(projectId, id);
} catch (error) {
console.error("Failed to delete media item:", error);
}
@@ -248,7 +224,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
set({ isLoading: true });
try {
- const mediaItems = await storageService.loadAllMediaItems(projectId);
+ const mediaItems = await storageService.loadAllMediaFiles(projectId);
// Regenerate thumbnails for video items
const updatedMediaItems = await Promise.all(
@@ -275,7 +251,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
})
);
- set({ mediaItems: updatedMediaItems });
+ set({ mediaFiles: updatedMediaItems });
} catch (error) {
console.error("Failed to load media items:", error);
} finally {
@@ -287,7 +263,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
const state = get();
// Cleanup all object URLs
- state.mediaItems.forEach((item) => {
+ state.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
@@ -297,13 +273,13 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
});
// Clear local state
- set({ mediaItems: [] });
+ set({ mediaFiles: [] });
// Clear persistent storage
try {
- const mediaIds = state.mediaItems.map((item) => item.id);
+ const mediaIds = state.mediaFiles.map((item) => item.id);
await Promise.all(
- mediaIds.map((id) => storageService.deleteMediaItem(projectId, id))
+ mediaIds.map((id) => storageService.deleteMediaFile(projectId, id))
);
} catch (error) {
console.error("Failed to clear media items from storage:", error);
@@ -314,7 +290,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
const state = get();
// Cleanup all object URLs
- state.mediaItems.forEach((item) => {
+ state.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
@@ -324,6 +300,6 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
});
// Clear local state
- set({ mediaItems: [] });
+ set({ mediaFiles: [] });
},
}));
diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts
index 215253fb..6dffa198 100644
--- a/apps/web/src/stores/sounds-store.ts
+++ b/apps/web/src/stores/sounds-store.ts
@@ -247,7 +247,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
type: "audio/mpeg",
});
- await useMediaStore.getState().addMediaItem(activeProject.id, {
+ await useMediaStore.getState().addMediaFile(activeProject.id, {
name: sound.name,
type: "audio",
file,
@@ -257,12 +257,12 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
const mediaItem = useMediaStore
.getState()
- .mediaItems.find((item) => item.file === file);
+ .mediaFiles.find((item) => item.file === file);
if (!mediaItem) throw new Error("Failed to create media item");
const success = useTimelineStore
.getState()
- .addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime);
+ .addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime);
if (success) {
return true;
diff --git a/apps/web/src/stores/text-properties-store.ts b/apps/web/src/stores/text-properties-store.ts
new file mode 100644
index 00000000..b3ce3689
--- /dev/null
+++ b/apps/web/src/stores/text-properties-store.ts
@@ -0,0 +1,33 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export type TextPropertiesTab = "transform" | "style";
+
+export interface TextPropertiesTabMeta {
+ value: TextPropertiesTab;
+ label: string;
+}
+
+export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
+ { value: "transform", label: "Transform" },
+ { value: "style", label: "Style" },
+] as const;
+
+export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
+ return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
+}
+
+interface TextPropertiesState {
+ activeTab: TextPropertiesTab;
+ setActiveTab: (tab: TextPropertiesTab) => void;
+}
+
+export const useTextPropertiesStore = create<TextPropertiesState>()(
+ persist(
+ (set) => ({
+ activeTab: "transform",
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ }),
+ { name: "text-properties" }
+ )
+);
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 3eada6de..f0bae00c 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -10,17 +10,15 @@ import {
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
-import {
- useMediaStore,
- getMediaAspectRatio,
- type MediaItem,
-} from "./media-store";
+import { useMediaStore, getMediaAspectRatio } from "./media-store";
+import { MediaFile } from "@/types/media";
import { findBestCanvasPreset } from "@/lib/editor-utils";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@@ -43,6 +41,11 @@ interface TimelineStore {
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
+ // Clipboard buffer
+ clipboard: {
+ items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
+ } | null;
+
// Always returns properly ordered tracks with main track ensured
tracks: TimelineTrack[];
@@ -178,6 +181,10 @@ interface TimelineStore {
loadProjectTimeline: (projectId: string) => Promise<void>;
saveProjectTimeline: (projectId: string) => Promise<void>;
clearTimeline: () => void;
+
+ // Clipboard actions
+ copySelected: () => void;
+ pasteAtTime: (time: number) => void;
updateTextElement: (
trackId: string,
elementId: string,
@@ -207,10 +214,11 @@ interface TimelineStore {
excludeElementId?: string
) => boolean;
findOrCreateTrack: (trackType: TrackType) => string;
- addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
- addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
- addMediaToNewTrack: (item: MediaItem) => boolean;
- addTextToNewTrack: (item: TextElement | DragData) => boolean;
+ addElementAtTime: (
+ item: MediaFile | TextElement,
+ currentTime?: number
+ ) => boolean;
+ addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
@@ -254,6 +262,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
redoStack: [],
selectedElements: [],
rippleEditingEnabled: false,
+ clipboard: null,
// Snapping settings defaults
snappingEnabled: true,
@@ -494,15 +503,17 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const newElement: TimelineElement = {
...elementData,
id: generateUUID(),
- startTime: elementData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- ...(elementData.type === "media" ? { muted: false } : {}),
+ startTime: elementData.startTime,
+ trimStart: elementData.trimStart ?? 0,
+ trimEnd: elementData.trimEnd ?? 0,
+ ...(elementData.type === "media"
+ ? { muted: elementData.muted ?? false }
+ : {}),
} as TimelineElement;
if (isFirstElement && newElement.type === "media") {
const mediaStore = useMediaStore.getState();
- const mediaItem = mediaStore.mediaItems.find(
+ const mediaItem = mediaStore.mediaFiles.find(
(item) => item.id === newElement.mediaId
);
@@ -1144,7 +1155,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
try {
- await mediaStore.addMediaItem(
+ await mediaStore.addMediaFile(
projectStore.activeProject.id,
mediaData
);
@@ -1155,7 +1166,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
}
- const newMediaItem = mediaStore.mediaItems.find(
+ const newMediaItem = mediaStore.mediaFiles.find(
(item) => item.file === newFile
);
@@ -1219,7 +1230,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
getProjectThumbnail: async (projectId) => {
try {
const tracks = await storageService.loadTimeline(projectId);
- const mediaItems = await storageService.loadAllMediaItems(projectId);
+ const mediaItems = await storageService.loadAllMediaFiles(projectId);
if (!tracks || !mediaItems.length) return null;
@@ -1230,20 +1241,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (!firstMediaElement) return null;
- const mediaItem = mediaItems.find(
+ const mediaFile = mediaItems.find(
(item) => item.id === firstMediaElement.mediaId
);
- if (!mediaItem) return null;
+ if (!mediaFile) return null;
- if (mediaItem.type === "video" && mediaItem.file) {
+ if (mediaFile.type === "video" && mediaFile.file) {
const { generateVideoThumbnail } = await import(
"@/stores/media-store"
);
- const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file);
+ const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file);
return thumbnailUrl;
}
- if (mediaItem.type === "image" && mediaItem.url) {
- return mediaItem.url;
+ if (mediaFile.type === "image" && mediaFile.url) {
+ return mediaFile.url;
}
return null;
@@ -1348,7 +1359,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
clearTimeline: () => {
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
- set({ history: [], redoStack: [], selectedElements: [] });
+ set({
+ history: [],
+ redoStack: [],
+ selectedElements: [],
+ clipboard: null,
+ });
},
// Snapping actions
@@ -1401,30 +1417,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return get().addTrack(trackType);
},
- addMediaAtTime: (item, currentTime = 0) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const duration =
- item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
-
- const tracks = get()._tracks.filter((t) => t.type === trackType);
-
- let targetTrackId = null;
- for (const track of tracks) {
- if (!get().checkElementOverlap(track.id, currentTime, duration)) {
- targetTrackId = track.id;
- break;
- }
- }
-
- if (!targetTrackId) {
- targetTrackId = get().addTrack(trackType);
+ addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => {
+ if (item.type === "text") {
+ const targetTrackId = get().insertTrackAt("text", 0);
+ get().addElementToTrack(
+ targetTrackId,
+ buildTextElement(item, currentTime)
+ );
+ return true;
}
+ const media = item as MediaFile;
+ const trackType = media.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().insertTrackAt(trackType, 0);
get().addElementToTrack(targetTrackId, {
type: "media",
- mediaId: item.id,
- name: item.name,
- duration,
+ mediaId: media.id,
+ name: media.name,
+ duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
@@ -1433,42 +1443,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return true;
},
- addTextAtTime: (item, currentTime = 0) => {
- const targetTrackId = get().insertTrackAt("text", 0);
-
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content: item.content || "Default Text",
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: item.fontSize || 48,
- fontFamily: item.fontFamily || "Arial",
- color: item.color || "#ffffff",
- backgroundColor: item.backgroundColor || "transparent",
- textAlign: item.textAlign || "center",
- fontWeight: item.fontWeight || "normal",
- fontStyle: item.fontStyle || "normal",
- textDecoration: item.textDecoration || "none",
- x: item.x || 0,
- y: item.y || 0,
- rotation: item.rotation || 0,
- opacity: item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
- },
-
- addMediaToNewTrack: (item) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addElementToNewTrack: (item) => {
+ if (item.type === "text") {
+ const targetTrackId = get().insertTrackAt("text", 0);
+ get().addElementToTrack(
+ targetTrackId,
+ buildTextElement(item as TextElement | DragData, 0)
+ );
+ return true;
+ }
+ const media = item as MediaFile;
+ const trackType = media.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().insertTrackAt(trackType, 0);
get().addElementToTrack(targetTrackId, {
type: "media",
- mediaId: item.id,
- name: item.name,
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ mediaId: media.id,
+ name: media.name,
+ duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
@@ -1477,40 +1469,113 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return true;
},
- addTextToNewTrack: (item) => {
- const targetTrackId = get().insertTrackAt("text", 0);
+ copySelected: () => {
+ const { selectedElements, _tracks } = get();
+ if (selectedElements.length === 0) return;
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content:
- ("content" in item ? item.content : "Default Text") || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
- fontFamily:
- ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
- color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
- backgroundColor:
- ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
- "transparent",
- textAlign:
- ("textAlign" in item ? item.textAlign : "center") || "center",
- fontWeight:
- ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
- fontStyle:
- ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
- textDecoration:
- ("textDecoration" in item ? item.textDecoration : "none") || "none",
- x: ("x" in item ? item.x : 0) || 0,
- y: ("y" in item ? item.y : 0) || 0,
- rotation: ("rotation" in item ? item.rotation : 0) || 0,
- opacity:
- "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
+ const items: Array<{
+ trackType: TrackType;
+ element: CreateTimelineElement;
+ }> = [];
+
+ for (const { trackId, elementId } of selectedElements) {
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ if (!track || !element) continue;
+
+ // Prepare a creation-friendly copy without id
+ const { id: _id, ...rest } = element as TimelineElement;
+ items.push({
+ trackType: track.type,
+ element: rest as CreateTimelineElement,
+ });
+ }
+
+ set({ clipboard: { items } });
+ },
+
+ pasteAtTime: (time) => {
+ const { clipboard } = get();
+ if (!clipboard || clipboard.items.length === 0) return;
+
+ // Determine reference start time offset based on earliest element in clipboard
+ const minStart = Math.min(
+ ...clipboard.items.map((x) => x.element.startTime)
+ );
+
+ get().pushHistory();
+
+ for (const item of clipboard.items) {
+ const targetTrackId = get().findOrCreateTrack(item.trackType);
+ const relativeOffset = item.element.startTime - minStart;
+ const startTime = Math.max(0, time + relativeOffset);
+
+ // Ensure no overlap on target track
+ const duration =
+ item.element.duration - item.element.trimStart - item.element.trimEnd;
+ const hasOverlap = get().checkElementOverlap(
+ targetTrackId,
+ startTime,
+ duration
+ );
+ if (hasOverlap) {
+ // If overlap, nudge forward slightly until free (simple resolve)
+ let candidate = startTime;
+ let safety = 0;
+ while (
+ get().checkElementOverlap(targetTrackId, candidate, duration) &&
+ safety < 1000
+ ) {
+ candidate += 0.01;
+ safety += 1;
+ }
+ get().addElementToTrack(targetTrackId, {
+ ...item.element,
+ startTime: candidate,
+ });
+ } else {
+ get().addElementToTrack(targetTrackId, {
+ ...item.element,
+ startTime,
+ });
+ }
+ }
},
};
});
+
+function buildTextElement(
+ raw: TextElement | DragData,
+ 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_TEXT_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,
+ x: typeof t.x === "number" ? t.x : DEFAULT_TEXT_ELEMENT.x,
+ y: typeof t.y === "number" ? t.y : DEFAULT_TEXT_ELEMENT.y,
+ rotation:
+ typeof t.rotation === "number"
+ ? t.rotation
+ : DEFAULT_TEXT_ELEMENT.rotation,
+ opacity:
+ typeof t.opacity === "number" ? t.opacity : DEFAULT_TEXT_ELEMENT.opacity,
+ };
+}
diff --git a/apps/web/src/types/export.ts b/apps/web/src/types/export.ts
new file mode 100644
index 00000000..093268ce
--- /dev/null
+++ b/apps/web/src/types/export.ts
@@ -0,0 +1,18 @@
+export type ExportFormat = "mp4" | "webm";
+export type ExportQuality = "low" | "medium" | "high" | "very_high";
+
+export interface ExportOptions {
+ format: ExportFormat;
+ quality: ExportQuality;
+ fps?: number;
+ includeAudio?: boolean;
+ onProgress?: (progress: number) => void;
+ onCancel?: () => boolean;
+}
+
+export interface ExportResult {
+ success: boolean;
+ buffer?: ArrayBuffer;
+ error?: string;
+ cancelled?: boolean;
+}
diff --git a/apps/web/src/types/media.ts b/apps/web/src/types/media.ts
new file mode 100644
index 00000000..6e94fcff
--- /dev/null
+++ b/apps/web/src/types/media.ts
@@ -0,0 +1,17 @@
+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;
+}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index cf946453..61ba6af0 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,4 +1,4 @@
-import { MediaType } from "@/stores/media-store";
+import { MediaType } from "@/types/media";
import { generateUUID } from "@/lib/utils";
export type TrackType = "media" | "text" | "audio";