diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc index 392683c8..4a6b29fe 100644 --- a/.cursor/rules/ultracite.mdc +++ b/.cursor/rules/ultracite.mdc @@ -42,9 +42,13 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Don't insert comments as text nodes. - Use `<>...` instead of `...`. +### Function Parameters and Props +- Always use destructured props objects instead of individual parameters in functions. +- Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`. +- This applies to all functions, not just React components. + ### Correctness and Safety - Don't assign a value to itself. -- Use props instead of parameters in all functions. - Avoid unused imports and variables. - Don't use await inside loops. - Don't hardcode sensitive data like API keys and tokens. @@ -86,4 +90,4 @@ try { } catch (e) { console.log(e); } -``` \ No newline at end of file +``` diff --git a/.gitignore b/.gitignore index b4756346..31d36b92 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ node_modules # cursor bun.lockb apps/transcription/__pycache__ -apps/transcription/env \ No newline at end of file +apps/transcription/env + +apps/bg-remover/env \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8bd9863d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,168 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenCut is a free, open-source video editor built with Next.js, focusing on privacy (no server processing), multi-track timeline editing, and real-time preview. The project is a monorepo using Turborepo with multiple apps including a web application, desktop app (Tauri), background remover tools, and transcription services. + +## Essential Commands + +**Development:** +```bash +# Root level development +bun dev # Start all apps in development mode +bun build # Build all apps +bun lint # Lint all code using Ultracite +bun format # Format all code using Ultracite + +# Web app specific (from apps/web/) +cd apps/web +bun run dev # Start Next.js development server with Turbopack +bun run build # Build for production +bun run lint # Run Biome linting +bun run lint:fix # Fix linting issues automatically +bun run format # Format code with Biome + +# Database operations (from apps/web/) +bun run db:generate # Generate Drizzle migrations +bun run db:migrate # Run migrations +bun run db:push:local # Push schema to local development database +bun run db:push:prod # Push schema to production database +``` + +**Testing:** +- No unified test commands are currently configured +- Individual apps may have their own test setups + +## Architecture & Key Components + +### State Management +The application uses **Zustand** for state management with separate stores for different concerns: +- **editor-store.ts**: Canvas presets, layout guides, app initialization +- **timeline-store.ts**: Timeline tracks, elements, playback state +- **media-store.ts**: Media files and asset management +- **playback-store.ts**: Video playback controls and timing +- **project-store.ts**: Project-level data and persistence +- **panel-store.ts**: UI panel visibility and layout +- **keybindings-store.ts**: Keyboard shortcut management +- **sounds-store.ts**: Audio effects and sound management +- **stickers-store.ts**: Sticker/graphics management + +### Storage System +**Multi-layer storage approach:** +- **IndexedDB**: Projects, saved sounds, and structured data +- **OPFS (Origin Private File System)**: Large media files for better performance +- **Storage Service** (`lib/storage/`): Abstraction layer managing both storage types + +### Editor Architecture +**Core editor components:** +- **Timeline Canvas**: Custom canvas-based timeline with tracks and elements +- **Preview Panel**: Real-time video preview (currently DOM-based, planned binary refactor) +- **Media Panel**: Asset management with drag-and-drop support +- **Properties Panel**: Context-sensitive element properties + +### Media Processing +- **FFmpeg Integration**: Client-side video processing using @ffmpeg/ffmpeg +- **Background Removal**: Python-based tools with multiple AI models (U2Net, SAM, Gemini) +- **Transcription**: Separate service for audio-to-text conversion + +## Development Focus Areas + +**✅ Recommended contribution areas:** +- Timeline functionality and UI improvements +- Project management features +- Performance optimizations +- Bug fixes in existing functionality +- UI/UX improvements outside preview panel +- Documentation and testing + +**⚠️ Areas to avoid (pending refactor):** +- Preview panel enhancements (fonts, stickers, effects) +- Export functionality improvements +- Preview rendering optimizations + +**Reason:** The preview system is planned for a major refactor from DOM-based rendering to binary rendering for consistency with export and better performance. + +## Code Quality Standards + +**Linting & Formatting:** +- Uses **Biome** for JavaScript/TypeScript linting and formatting +- Extends **Ultracite** configuration for strict type safety and AI-friendly code +- Comprehensive accessibility (a11y) rules enforced +- Zero configuration approach with subsecond performance + +**Key coding standards from Ultracite:** +- Strict TypeScript with no `any` types +- No React imports (uses automatic JSX runtime) +- Comprehensive accessibility requirements +- Use `for...of` instead of `Array.forEach` +- No TypeScript enums, use const objects +- Always include error handling with try-catch + +## Environment Setup + +**Required environment variables (apps/web/.env.local):** +```bash +# Database +DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" + +# Authentication +BETTER_AUTH_SECRET="your-generated-secret-here" +BETTER_AUTH_URL="http://localhost:3000" + +# Redis +UPSTASH_REDIS_REST_URL="http://localhost:8079" +UPSTASH_REDIS_REST_TOKEN="example_token" + +# Content Management +MARBLE_WORKSPACE_KEY="workspace-key" +NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com" +``` + +**Docker services:** +```bash +# Start local database and Redis +docker-compose up -d +``` + +## Project Structure + +**Monorepo layout:** +- `apps/web/` - Main Next.js application +- `apps/desktop/` - Tauri desktop application +- `apps/bg-remover/` - Python background removal tools +- `apps/transcription/` - Audio transcription service +- `packages/` - Shared packages (auth, database) + +**Web app structure:** +- `src/components/` - React components organized by feature +- `src/stores/` - Zustand state management +- `src/hooks/` - Custom React hooks +- `src/lib/` - Utility functions and services +- `src/types/` - TypeScript type definitions +- `src/app/` - Next.js app router pages and API routes + +## Common Patterns + +**Error handling:** +```typescript +try { + const result = await processData(); + return { success: true, data: result }; +} catch (error) { + console.error('Operation failed:', error); + return { success: false, error: error.message }; +} +``` + +**Store usage:** +```typescript +const { tracks, addTrack, updateTrack } = useTimelineStore(); +``` + +**Media processing:** +```typescript +import { processVideo } from '@/lib/ffmpeg-utils'; +const processedVideo = await processVideo(inputFile, options); +``` \ No newline at end of file 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 ( - - - - - -
-

Background

-
- {tabs.map((tab) => ( - { - // 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} - - ))} -
-
- {activeTab === "color" ? ( - - ) : ( - - )} -
-
- ); -} - -function ColorView({ - selectedColor, - onColorSelect, -}: { - selectedColor: string; - onColorSelect: (color: string) => void; -}) { - return ( -
-
-
-
- -
- {colors.map((color) => ( - onColorSelect(color)} - /> - ))} -
-
- ); -} - -function ColorItem({ - color, - isSelected, - onClick, -}: { - color: string; - isSelected: boolean; - onClick: () => void; -}) { - return ( -
- ); -} - -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 ( -
- {blurLevels.map((blur) => ( -
onBlurSelect(blur.value)} - > - {`Blur -
- - {blur.label} - -
-
- ))} -
- ); -} 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 = ({ }); 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 = ({ 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 similarity index 93% rename from apps/web/src/components/editor-header.tsx rename to 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 ( + + + + + {hasProject && } + + ); +} + +function ExportPopover({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const { activeProject } = useProjectStore(); + const [format, setFormat] = useState( + DEFAULT_EXPORT_OPTIONS.format + ); + const [quality, setQuality] = useState( + DEFAULT_EXPORT_OPTIONS.quality + ); + const [includeAudio, setIncludeAudio] = useState( + DEFAULT_EXPORT_OPTIONS.includeAudio || true + ); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportResult, setExportResult] = useState(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 ( + + <> +
+

+ {isExporting ? "Exporting project" : "Export project"} +

+ +
+ +
+ {!isExporting && ( + <> +
+ + setFormat(value as ExportFormat)} + > +
+ + +
+
+ + +
+
+
+ + + + setQuality(value as ExportQuality) + } + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ setIncludeAudio(!!checked)} + /> + +
+
+
+ + + + )} + + {isExporting && ( +
+
+
+

+ {Math.round(progress * 100)}% +

+

100%

+
+ +
+ + +
+ )} + + {exportResult && !exportResult.success && ( +
+
Export failed
+

+ {exportResult.error || "Unknown error occurred"} +

+ +
+ )} +
+ +
+ ); +} 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; }) { @@ -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(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) => { - // 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(); @@ -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; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return (
- 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; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return (
{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 = { 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() {
} 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 similarity index 97% rename from apps/web/src/components/onboarding.tsx rename to 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 similarity index 60% rename from apps/web/src/components/editor/media-panel/views/base-view.tsx rename to 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 (
{!tabs || tabs.length === 0 ? ( {children} ) : ( - -
- - {tabs.map((tab) => ( - - {tab.label} - - ))} - + +
+
+ + {tabs.map((tab) => ( + + {tab.label} + + ))} + +
+
- {tabs.map((tab) => ( -
+
Panel Presets
@@ -59,7 +59,7 @@ export function PanelPresetSelector() { 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" >
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(null); + const canvasRef = useRef(null); + const lastFrameTimeRef = useRef(0); + const renderSeqRef = useRef(0); + const offscreenCanvasRef = useRef( + null + ); + + const audioContextRef = useRef(null); + const audioGainRef = useRef(null); + const audioBuffersRef = useRef>(new Map()); + const playingSourcesRef = useRef>(new Set()); const containerRef = useRef(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,6 +277,295 @@ export function PreviewPanel() { const activeElements = getActiveElements(); + // 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); + }; + }, []); + + // 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(); + }; + + 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 + ); + }; + + const scheduleNow = async () => { + await ensureAudioGraph(); + const audioCtx = audioContextRef.current!; + const gain = audioGainRef.current!; + + 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; + + const audible: Array<{ + id: string; + elementStart: number; + trimStart: number; + trimEnd: number; + duration: number; + muted: boolean; + trackMuted: boolean; + }> = []; + const uniqueIds = new Set(); + 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 (audible.length === 0) return; + + // Decode buffers as needed + const decodePromises: Array> = []; + 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); + + 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 {} + } + }; + + const onSeek = () => { + if (!isPlaying) return; + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + void scheduleNow(); + }; + + // Apply volume/mute changes immediately + void ensureAudioGraph(); + + // Start/stop on play state changes + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + if (isPlaying) { + void scheduleNow(); + } + + 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]); + + // 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; + } + + // 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; + } + + // 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, + ]); + // Get media elements for blur background (video/image only) const getBlurBackgroundElements = (): ActiveElement[] => { return activeElements.filter( @@ -275,213 +579,11 @@ export function PreviewPanel() { const blurBackgroundElements = getBlurBackgroundElements(); - // Render blur background layer - const renderBlurBackground = () => { - if ( - !activeProject?.backgroundType || - activeProject.backgroundType !== "blur" || - blurBackgroundElements.length === 0 - ) { - return null; - } + // Render blur background layer (handled by canvas now) + const renderBlurBackground = () => null; - // Use the first media element for background (could be enhanced to use primary/focused element) - const backgroundElement = blurBackgroundElements[0]; - const { element, mediaItem } = backgroundElement; - - if (!mediaItem) return null; - - const blurIntensity = activeProject.blurIntensity || 8; - - if (mediaItem.type === "video") { - return ( -
- -
- ); - } - - if (mediaItem.type === "image") { - return ( -
- {mediaItem.name} -
- ); - } - - return null; - }; - - // Render an element - const renderElement = (elementData: ActiveElement, index: number) => { - const { element, mediaItem } = elementData; - - // Text elements - if (element.type === "text") { - const fontClassName = - FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; - - const scaleRatio = previewDimensions.width / canvasSize.width; - - return ( -
- 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 - }} - > -
- {element.content} -
-
- ); - } - - // Media elements - if (element.type === "media") { - // Test elements - if (!mediaItem || element.mediaId === "test") { - return ( -
-
-
🎬
-

{element.name}

-
-
- ); - } - - // Video elements - if (mediaItem.type === "video") { - return ( -
- -
- ); - } - - // Image elements - if (mediaItem.type === "image") { - return ( -
- {mediaItem.name} -
- ); - } - - // Audio elements (no visual representation) - if (mediaItem.type === "audio") { - return ( -
- -
- ); - } - } - - return null; - }; + // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio. + const renderElement = (_elementData: ActiveElement) => null; return ( <> @@ -505,14 +607,23 @@ export function PreviewPanel() { }} > {renderBlurBackground()} + {activeElements.length === 0 ? (
No elements at current time
) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) + activeElements.map((elementData) => renderElement(elementData)) )}
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 ; } 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)} > - + {title} 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(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 ( -
-