Merge branch 'staging'

This commit is contained in:
Maze Winther 2025-08-28 22:39:19 +02:00
commit 2d834a49d7
57 changed files with 2486 additions and 1456 deletions

View File

@ -42,9 +42,13 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Don't insert comments as text nodes.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
### 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);
}
```
```

4
.gitignore vendored
View File

@ -29,4 +29,6 @@ node_modules
# cursor
bun.lockb
apps/transcription/__pycache__
apps/transcription/env
apps/transcription/env
apps/bg-remover/env

168
CLAUDE.md Normal file
View File

@ -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);
```

View File

@ -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 {

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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);
});

View File

@ -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}

View File

@ -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,

View File

@ -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)}

View File

@ -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}
/>

View File

@ -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";

View File

@ -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}

View File

@ -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">

View File

@ -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,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<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 (audible.length === 0) return;
// 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);
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 (
<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>
);
}
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>
);
}
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 (
<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>
);
}
// 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>
);
}
// 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>
);
}
// 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>
);
}
// 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>
);
}
}
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()}
<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>

View File

@ -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} />;
}

View File

@ -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")} />

View File

@ -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>
/>
<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="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>
<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>
</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
[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
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
),
}))}
/>
);
}

View File

@ -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);
}
}
};

View File

@ -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 ? (

View File

@ -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

View File

@ -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>
);
}

View File

@ -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}
/>

View File

@ -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"

View File

@ -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>

View File

@ -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 };

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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}
/>
);

View File

@ -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}

View File

@ -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

View File

@ -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,
};

View File

@ -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);
}
}
}

View File

@ -202,6 +202,23 @@ export function useEditorActions() {
undefined
);
useActionHandler(
"copy-selected",
() => {
if (selectedElements.length === 0) return;
useTimelineStore.getState().copySelected();
},
undefined
);
useActionHandler(
"paste-selected",
() => {
useTimelineStore.getState().pasteAtTime(currentTime);
},
undefined
);
useActionHandler(
"toggle-snapping",
() => {

View File

@ -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

View File

@ -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,

View File

@ -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;
}

296
apps/web/src/lib/export.ts Normal file
View File

@ -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}`;
}

View File

@ -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;

View File

@ -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();
export async function generateThumbnail({
videoFile,
timeInSeconds,
}: {
videoFile: File;
timeInSeconds: number;
}): Promise<string> {
const input = new Input({
source: new BlobSource(videoFile),
formats: ALL_FORMATS,
});
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,
]);
// 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);
return URL.createObjectURL(blob);
};
export const trimVideo = async (
videoFile: File,
startTime: number,
endTime: number,
onProgress?: (progress: number) => void
): Promise<Blob> => {
const ffmpeg = await initFFmpeg();
const inputName = "input.mp4";
const outputName = "output.mp4";
// Set up progress callback
if (onProgress) {
ffmpeg.on("progress", ({ progress }) => {
onProgress(progress * 100);
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found in the file");
}
// Write input file
await ffmpeg.writeFile(
inputName,
new Uint8Array(await videoFile.arrayBuffer())
);
// Check if we can decode this video
const canDecode = await videoTrack.canDecode();
if (!canDecode) {
throw new Error("Video codec not supported for decoding");
}
const duration = endTime - startTime;
const sink = new VideoSampleSink(videoTrack);
// Trim video
await ffmpeg.exec([
"-i",
inputName,
"-ss",
startTime.toString(),
"-t",
duration.toString(),
"-c",
"copy", // Use stream copy for faster processing
outputName,
]);
const frame = await sink.getSample(timeInSeconds);
// Read output file
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: "video/mp4" });
if (!frame) {
throw new Error("Could not get frame at specified time");
}
// Cleanup
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 240;
const ctx = canvas.getContext("2d");
return blob;
};
if (!ctx) {
throw new Error("Could not get canvas context");
}
export const getVideoInfo = async (
videoFile: File
): Promise<{
frame.draw(ctx, 0, 0, 320, 240);
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 async function getVideoInfo({
videoFile,
}: {
videoFile: File;
}): Promise<{
duration: number;
width: number;
height: number;
fps: number;
}> => {
const ffmpeg = await initFFmpeg();
}> {
const input = new Input({
source: new BlobSource(videoFile),
formats: ALL_FORMATS,
});
const inputName = "input.mp4";
const duration = await input.computeDuration();
const videoTrack = await input.getPrimaryVideoTrack();
// 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."
);
if (!videoTrack) {
throw new Error("No video track found in the file");
}
// Disable listener after exec completes
listening = false;
// 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);
}
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,

View File

@ -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);

View File

@ -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();
}
}
}

View File

@ -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

View File

@ -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,
}
)
);

View File

@ -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: [] });
},
}));

View File

@ -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;

View File

@ -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" }
)
);

View File

@ -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,116 +1417,165 @@ 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;
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 tracks = get()._tracks.filter((t) => t.type === trackType);
const media = item as MediaFile;
const trackType = media.type === "audio" ? "audio" : "media";
const targetTrackId = get().insertTrackAt(trackType, 0);
get().addElementToTrack(targetTrackId, {
type: "media",
mediaId: media.id,
name: media.name,
duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
muted: false,
});
return true;
},
let targetTrackId = null;
for (const track of tracks) {
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
targetTrackId = track.id;
break;
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: media.id,
name: media.name,
duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
muted: false,
});
return true;
},
copySelected: () => {
const { selectedElements, _tracks } = get();
if (selectedElements.length === 0) return;
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,
});
}
}
if (!targetTrackId) {
targetTrackId = get().addTrack(trackType);
}
get().addElementToTrack(targetTrackId, {
type: "media",
mediaId: item.id,
name: item.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
muted: false,
});
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);
get().addElementToTrack(targetTrackId, {
type: "media",
mediaId: item.id,
name: item.name,
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
muted: false,
});
return true;
},
addTextToNewTrack: (item) => {
const targetTrackId = get().insertTrackAt("text", 0);
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;
},
};
});
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,
};
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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";

View File

@ -4,6 +4,7 @@
"": {
"name": "opencut",
"dependencies": {
"mediabunny": "^1.9.3",
"next": "^15.3.4",
"react-country-flag": "^3.1.0",
"wavesurfer.js": "^7.9.8",
@ -532,7 +533,7 @@
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
@ -558,6 +559,10 @@
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
"@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@ -896,6 +901,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mediabunny": ["mediabunny@1.9.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-uVspdzrpwvW8NxtGspHPOJvdNSY0dNFEmOQngSheX9PYX9oXijZENBjfYWOgm9c9IgSSlN2bdJF9iyPyY3f+fQ=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@ -1250,6 +1257,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"better-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"motion/framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="],
@ -1316,14 +1325,16 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
"@types/bun/bun-types/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
"opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"opencut/next/@next/env": ["@next/env@15.4.5", "", {}, "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ=="],
"opencut/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA=="],
@ -1344,6 +1355,8 @@
"opencut/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"opencut/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
}
}

View File

@ -20,6 +20,7 @@
"format": "npx ultracite@latest format"
},
"dependencies": {
"mediabunny": "^1.9.3",
"next": "^15.3.4",
"react-country-flag": "^3.1.0",
"wavesurfer.js": "^7.9.8"