Merge branch 'staging'
This commit is contained in:
commit
2d834a49d7
|
|
@ -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);
|
||||
}
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -29,4 +29,6 @@ node_modules
|
|||
# cursor
|
||||
bun.lockb
|
||||
apps/transcription/__pycache__
|
||||
apps/transcription/env
|
||||
apps/transcription/env
|
||||
|
||||
apps/bg-remover/env
|
||||
|
|
@ -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);
|
||||
```
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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}
|
||||
|
|
@ -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">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
)
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
19
bun.lock
19
bun.lock
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue