Compare commits
1 Commits
main
...
mazeincodi
Author | SHA1 | Date | |
---|---|---|---|
2831c75982 |
5
.github/CONTRIBUTING.md
vendored
5
.github/CONTRIBUTING.md
vendored
@ -10,11 +10,6 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
4. Install dependencies: `bun install`
|
||||
5. Start the development server: `bun run dev`
|
||||
|
||||
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
||||
>
|
||||
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
|
||||
> 2. Use an alternative package manager such as **bun** or **pnpm**.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
4
.github/workflows/bun-ci.yml
vendored
4
.github/workflows/bun-ci.yml
vendored
@ -31,13 +31,13 @@ jobs:
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||
with:
|
||||
bun-version: 1.2.18
|
||||
bun-version: 1.2.17
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-1.2.18-${{ hashFiles('apps/web/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/web
|
||||
|
118
README.md
118
README.md
@ -45,95 +45,70 @@ Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
### Setup
|
||||
|
||||
## Getting Started
|
||||
1. **Clone the repository**
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Navigate to the web app directory: `cd apps/web`
|
||||
4. Install dependencies: `bun install`
|
||||
5. Start the development server: `bun run dev`
|
||||
```bash
|
||||
git clone https://github.com/OpenCut-app/OpenCut.git
|
||||
cd OpenCut
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
2. **Start backend services**
|
||||
From the project root, start the PostgreSQL and Redis services:
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
- Node.js 18+
|
||||
- Bun (latest version)
|
||||
- Docker (for local database)
|
||||
3. **Set up environment variables**
|
||||
Navigate into the web app's directory and create a `.env` file from the example:
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
cd apps/web
|
||||
|
||||
1. Start the database and Redis services:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
docker-compose up -d
|
||||
```
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
2. Navigate to the web app directory:
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
```
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
3. Copy `.env.example` to `.env.local`:
|
||||
_The default values in the `.env` file should work for local development._
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
4. **Install dependencies**
|
||||
Install the project dependencies using `bun` (recommended) or `npm`.
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
```bash
|
||||
# With bun
|
||||
bun install
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Configure required environment variables in `.env.local`:
|
||||
5. **Run database migrations**
|
||||
Apply the database schema to your local database:
|
||||
|
||||
**Required Variables:**
|
||||
```bash
|
||||
# With bun
|
||||
bun run db:push:local
|
||||
|
||||
```bash
|
||||
# Database (matches docker-compose.yaml)
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
# Or with npm
|
||||
npm run db:push:local
|
||||
```
|
||||
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
6. **Start the development server**
|
||||
|
||||
# Redis (matches docker-compose.yaml)
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
```bash
|
||||
# With bun
|
||||
bun run dev
|
||||
|
||||
# Development
|
||||
NODE_ENV="development"
|
||||
```
|
||||
|
||||
**Generate BETTER_AUTH_SECRET:**
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
openssl rand -base64 32
|
||||
|
||||
# Windows PowerShell (simple method)
|
||||
[System.Web.Security.Membership]::GeneratePassword(32, 0)
|
||||
|
||||
# Cross-platform (using Node.js)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
|
||||
# Or use an online generator: https://generate-secret.vercel.app/32
|
||||
```
|
||||
|
||||
**Optional Variables (for Google OAuth):**
|
||||
|
||||
```bash
|
||||
# Only needed if you want to test Google login
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
```
|
||||
|
||||
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
|
||||
6. Start the development server: `bun run dev` from (inside apps/web)
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
@ -163,4 +138,5 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss
|
||||
|
||||
---
|
||||
|
||||

|
||||
[]
|
||||
|
||||
|
@ -2,9 +2,9 @@
|
||||
"name": "opencut",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.18",
|
||||
"packageManager": "bun@1.2.17",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@ -68,4 +68,4 @@
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
@ -60,7 +60,7 @@ export default function Editor() {
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
className="h-full w-full gap-1"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
@ -72,7 +72,7 @@ export default function Editor() {
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-2"
|
||||
className="h-full w-full gap-1 px-2"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
|
@ -82,7 +82,5 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark">
|
||||
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
|
||||
<TooltipProvider>
|
||||
<StorageProvider>{children}</StorageProvider>
|
||||
<Analytics />
|
||||
@ -30,11 +30,10 @@ export default function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
async
|
||||
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
||||
data-track-attributes={false}
|
||||
data-track-attributes={true}
|
||||
data-track-errors={true}
|
||||
data-track-outgoing-links={false}
|
||||
data-track-web-vitals={false}
|
||||
data-track-sessions={false}
|
||||
data-track-outgoing-links={true}
|
||||
data-track-web-vitals={true}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
@ -74,9 +74,9 @@ export default function ProjectsPage() {
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
await Promise.all(
|
||||
Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
|
||||
);
|
||||
for (const projectId of selectedProjects) {
|
||||
await deleteProject(projectId);
|
||||
}
|
||||
setSelectedProjects(new Set());
|
||||
setIsSelectionMode(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
|
@ -40,9 +40,9 @@ export function BackgroundSettings() {
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 border border-muted-foreground"
|
||||
className="!size-5 border border-muted-foreground"
|
||||
>
|
||||
<BackgroundIcon className="!size-3" />
|
||||
<BackgroundIcon className="!size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
|
||||
|
@ -31,24 +31,13 @@ export function EditorHeader() {
|
||||
|
||||
const centerContent = (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
<span>{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Button size="sm" variant="primary" className="h-7 text-xs" onClick={handleExport}>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm">Export</span>
|
||||
</Button>
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Play, Pause, Expand } from "lucide-react";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
@ -390,7 +390,6 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const {
|
||||
currentPreset,
|
||||
isOriginal,
|
||||
@ -421,19 +420,11 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
)}
|
||||
>
|
||||
<span className="text-primary tabular-nums">
|
||||
{formatTimeCode(
|
||||
currentTime,
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
{formatTimeCode(currentTime, "HH:MM:SS:CS")}
|
||||
</span>
|
||||
<span className="opacity-50">/</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@ -456,7 +447,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
||||
className="!bg-panel-accent text-foreground/85 text-[0.75rem] h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
||||
disabled={!hasAnyElements}
|
||||
>
|
||||
{getDisplayName()}
|
||||
@ -484,13 +475,6 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 text-muted-foreground"
|
||||
>
|
||||
<Expand className="!size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -9,28 +9,13 @@ import { useMediaStore } from "@/stores/media-store";
|
||||
import { AudioProperties } from "./audio-properties";
|
||||
import { MediaProperties } from "./media-properties";
|
||||
import { TextProperties } from "./text-properties";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../ui/select";
|
||||
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { activeProject, updateProjectFps } = useProjectStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { getDisplayName, canvasSize } = useAspectRatio();
|
||||
const { selectedElements, tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
if (!isNaN(fps) && fps > 0) {
|
||||
updateProjectFps(fps);
|
||||
}
|
||||
};
|
||||
|
||||
const emptyView = (
|
||||
<div className="space-y-4 p-5">
|
||||
{/* Media Properties */}
|
||||
@ -41,24 +26,7 @@ export function PropertiesPanel() {
|
||||
label="Resolution:"
|
||||
value={`${canvasSize.width} × ${canvasSize.height}`}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs text-muted-foreground">Frame rate:</Label>
|
||||
<Select
|
||||
value={(activeProject?.fps || 30).toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map(({ value, label }) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<PropertyItem label="Frame rate:" value="30.00fps" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -17,11 +17,7 @@ import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
DragData,
|
||||
} from "@/types/timeline";
|
||||
import {
|
||||
snapTimeToFrame,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
@ -84,10 +80,7 @@ export function TimelineTrackContent({
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||
const snappedTime = Math.round(adjustedTime * 10) / 10;
|
||||
|
||||
updateDragTime(snappedTime);
|
||||
};
|
||||
@ -349,9 +342,7 @@ export function TimelineTrackContent({
|
||||
if (dragData.type === "text") {
|
||||
// Text elements have default duration of 5 seconds
|
||||
const newElementDuration = 5;
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(dropTime, projectFps);
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
@ -370,9 +361,7 @@ export function TimelineTrackContent({
|
||||
);
|
||||
if (mediaItem) {
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(dropTime, projectFps);
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
@ -412,9 +401,7 @@ export function TimelineTrackContent({
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(dropTime, projectFps);
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const movingElementEnd = snappedTime + movingElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
@ -441,17 +428,13 @@ export function TimelineTrackContent({
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
setDropPosition(snapTimeToFrame(dropTime, projectFps));
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
setDropPosition(snapTimeToFrame(dropTime, projectFps));
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
@ -519,9 +502,7 @@ export function TimelineTrackContent({
|
||||
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
|
||||
const newStartTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(newStartTime, projectFps);
|
||||
const snappedTime = Math.round(newStartTime * 10) / 10;
|
||||
|
||||
// Calculate drop position relative to tracks
|
||||
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
|
||||
@ -567,7 +548,7 @@ export function TimelineTrackContent({
|
||||
const adjustedStartTime = snappedTime - clickOffsetTime;
|
||||
const finalStartTime = Math.max(
|
||||
0,
|
||||
snapTimeToFrame(adjustedStartTime, projectFps)
|
||||
Math.round(adjustedStartTime * 10) / 10
|
||||
);
|
||||
|
||||
// Check for overlaps with existing elements (excluding the moving element itself)
|
||||
|
@ -81,8 +81,16 @@ export function Timeline() {
|
||||
} = useTimelineStore();
|
||||
const { mediaItems, addMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
|
||||
usePlaybackStore();
|
||||
const {
|
||||
currentTime,
|
||||
duration,
|
||||
seek,
|
||||
setDuration,
|
||||
isPlaying,
|
||||
toggle,
|
||||
setSpeed,
|
||||
speed,
|
||||
} = usePlaybackStore();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
@ -111,11 +119,9 @@ export function Timeline() {
|
||||
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isUpdatingRef = useRef(false);
|
||||
const lastRulerSync = useRef(0);
|
||||
const lastTracksSync = useRef(0);
|
||||
const lastVerticalSync = useRef(0);
|
||||
|
||||
// Timeline playhead ruler handlers
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
|
||||
@ -211,7 +217,7 @@ export function Timeline() {
|
||||
scrollLeft = tracksContent.scrollLeft;
|
||||
}
|
||||
|
||||
const rawTime = Math.max(
|
||||
const time = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
@ -220,11 +226,6 @@ export function Timeline() {
|
||||
)
|
||||
);
|
||||
|
||||
// Use frame snapping for timeline clicking
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
const { snapTimeToFrame } = require("@/constants/timeline-constants");
|
||||
const time = snapTimeToFrame(rawTime, projectFps);
|
||||
|
||||
seek(time);
|
||||
},
|
||||
[
|
||||
@ -607,13 +608,7 @@ export function Timeline() {
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
// Horizontal scroll synchronization between ruler and tracks
|
||||
const handleRulerScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
|
||||
@ -630,48 +625,8 @@ export function Timeline() {
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
rulerViewport.addEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksScroll);
|
||||
|
||||
// Vertical scroll synchronization between track labels and tracks content
|
||||
if (trackLabelsViewport) {
|
||||
const handleTrackLabelsScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
const handleTracksVerticalScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
|
||||
|
||||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
trackLabelsViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTrackLabelsScroll
|
||||
);
|
||||
tracksViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTracksVerticalScroll
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
@ -815,6 +770,27 @@ export function Timeline() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
{/* Speed Control */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Select
|
||||
value={speed.toFixed(1)}
|
||||
onValueChange={(value) => setSpeed(parseFloat(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[90px] h-8">
|
||||
<SelectValue placeholder="1.0x" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.5">0.5x</SelectItem>
|
||||
<SelectItem value="1.0">1.0x</SelectItem>
|
||||
<SelectItem value="1.5">1.5x</SelectItem>
|
||||
<SelectItem value="2.0">2.0x</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Playback Speed</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@ -944,26 +920,24 @@ export function Timeline() {
|
||||
className="w-48 flex-shrink-0 border-r bg-panel-accent overflow-y-auto"
|
||||
data-track-labels
|
||||
>
|
||||
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
|
||||
style={{ height: `${getTrackHeight(track.type)}px` }}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
{track.muted && (
|
||||
<span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
|
||||
Muted
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
|
||||
style={{ height: `${getTrackHeight(track.type)}px` }}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{track.muted && (
|
||||
<span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
|
||||
Muted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -1,164 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { motion, useMotionValue, useTransform, PanInfo } from "motion/react";
|
||||
|
||||
interface HandlebarsProps {
|
||||
children: React.ReactNode;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
onRangeChange?: (left: number, right: number) => void;
|
||||
}
|
||||
|
||||
export function Handlebars({
|
||||
children,
|
||||
minWidth = 50,
|
||||
maxWidth = 400,
|
||||
onRangeChange,
|
||||
}: HandlebarsProps) {
|
||||
const [leftHandle, setLeftHandle] = useState(0);
|
||||
const [rightHandle, setRightHandle] = useState(maxWidth);
|
||||
const [contentWidth, setContentWidth] = useState(maxWidth);
|
||||
|
||||
const leftHandleX = useMotionValue(0);
|
||||
const rightHandleX = useMotionValue(maxWidth);
|
||||
|
||||
const visibleWidth = useTransform(
|
||||
[leftHandleX, rightHandleX],
|
||||
(values: number[]) => values[1] - values[0]
|
||||
);
|
||||
|
||||
const contentLeft = useTransform(leftHandleX, (left: number) => -left);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureRef.current) return;
|
||||
|
||||
const measureContent = () => {
|
||||
if (measureRef.current) {
|
||||
const width = measureRef.current.scrollWidth;
|
||||
const paddedWidth = width + 32;
|
||||
setContentWidth(paddedWidth);
|
||||
setRightHandle(paddedWidth);
|
||||
rightHandleX.set(paddedWidth);
|
||||
}
|
||||
};
|
||||
|
||||
measureContent();
|
||||
const timer = setTimeout(measureContent, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [children, rightHandleX]);
|
||||
|
||||
useEffect(() => {
|
||||
leftHandleX.set(leftHandle);
|
||||
}, [leftHandle, leftHandleX]);
|
||||
|
||||
useEffect(() => {
|
||||
rightHandleX.set(rightHandle);
|
||||
}, [rightHandle, rightHandleX]);
|
||||
|
||||
useEffect(() => {
|
||||
onRangeChange?.(leftHandle, rightHandle);
|
||||
}, [leftHandle, rightHandle, onRangeChange]);
|
||||
|
||||
const handleLeftDrag = (event: any, info: PanInfo) => {
|
||||
const newLeft = Math.max(
|
||||
0,
|
||||
Math.min(leftHandle + info.offset.x, rightHandle - minWidth)
|
||||
);
|
||||
setLeftHandle(newLeft);
|
||||
};
|
||||
|
||||
const handleRightDrag = (event: any, info: PanInfo) => {
|
||||
const newRight = Math.max(
|
||||
leftHandle + minWidth,
|
||||
Math.min(contentWidth, rightHandle + info.offset.x)
|
||||
);
|
||||
setRightHandle(newRight);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
|
||||
style={{ width: contentWidth }}
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
|
||||
<motion.div
|
||||
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
x: leftHandleX,
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: rightHandle - minWidth }}
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
onDrag={handleLeftDrag}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileDrag={{ scale: 1.1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
>
|
||||
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
x: rightHandleX,
|
||||
left: -30,
|
||||
zIndex: 10,
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{
|
||||
left: leftHandle + minWidth,
|
||||
right: contentWidth,
|
||||
}}
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
onDrag={handleRightDrag}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileDrag={{ scale: 1.1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
>
|
||||
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="relative overflow-hidden rounded-2xl"
|
||||
style={{
|
||||
width: visibleWidth,
|
||||
x: leftHandleX,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full h-full flex items-center justify-center px-4"
|
||||
style={{
|
||||
x: contentLeft,
|
||||
width: contentWidth,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -8,7 +8,6 @@ import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Handlebars } from "./handlebars";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
@ -92,7 +91,14 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||
>
|
||||
<h1>The Open Source</h1>
|
||||
<Handlebars>Video Editor</Handlebars>
|
||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
|
||||
<Image src="/frame.svg" height={79} width={459} alt="frame" />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
Video Editor
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
|
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
@ -28,7 +29,7 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
"relative w-0 bg-transparent cursor-col-resize data-[panel-group-direction=vertical]:h-0 data-[panel-group-direction=vertical]:cursor-row-resize",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@ -76,31 +76,3 @@ export const TIMELINE_CONSTANTS = {
|
||||
DEFAULT_TEXT_DURATION: 5,
|
||||
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
||||
} as const;
|
||||
|
||||
// FPS presets for project settings
|
||||
export const FPS_PRESETS = [
|
||||
{ value: "24", label: "24 fps (Film)" },
|
||||
{ value: "25", label: "25 fps (PAL)" },
|
||||
{ value: "30", label: "30 fps (NTSC)" },
|
||||
{ value: "60", label: "60 fps (High)" },
|
||||
{ value: "120", label: "120 fps (Slow-mo)" },
|
||||
] as const;
|
||||
|
||||
// Frame snapping utilities
|
||||
export function timeToFrame(time: number, fps: number): number {
|
||||
return Math.round(time * fps);
|
||||
}
|
||||
|
||||
export function frameToTime(frame: number, fps: number): number {
|
||||
return frame / fps;
|
||||
}
|
||||
|
||||
export function snapTimeToFrame(time: number, fps: number): number {
|
||||
if (fps <= 0) return time; // Fallback for invalid FPS
|
||||
const frame = timeToFrame(time, fps);
|
||||
return frameToTime(frame, fps);
|
||||
}
|
||||
|
||||
export function getFrameDuration(fps: number): number {
|
||||
return 1 / fps;
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ export function useLogin() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/projects");
|
||||
router.push("/editor");
|
||||
}, [router, email, password]);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
@ -35,7 +35,7 @@ export function useLogin() {
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/projects",
|
||||
callbackURL: "/editor",
|
||||
});
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { snapTimeToFrame } from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
@ -71,11 +69,7 @@ export function useTimelinePlayhead({
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
|
||||
// Use frame snapping for playhead scrubbing
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame(rawTime, projectFps);
|
||||
const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
},
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
getImageDimensions,
|
||||
type MediaItem,
|
||||
} from "@/stores/media-store";
|
||||
import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils";
|
||||
// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
|
||||
|
||||
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
||||
|
||||
@ -33,7 +33,6 @@ export async function processMediaFiles(
|
||||
let duration: number | undefined;
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let fps: number | undefined;
|
||||
|
||||
try {
|
||||
if (fileType === "image") {
|
||||
@ -42,31 +41,17 @@ export async function processMediaFiles(
|
||||
width = dimensions.width;
|
||||
height = dimensions.height;
|
||||
} else if (fileType === "video") {
|
||||
try {
|
||||
// Use FFmpeg for comprehensive video info extraction
|
||||
const videoInfo = await getVideoInfo(file);
|
||||
duration = videoInfo.duration;
|
||||
width = videoInfo.width;
|
||||
height = videoInfo.height;
|
||||
fps = videoInfo.fps;
|
||||
|
||||
// Generate thumbnail using FFmpeg
|
||||
thumbnailUrl = await generateThumbnail(file, 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
|
||||
}
|
||||
// Use basic thumbnail generation for now
|
||||
const videoResult = await generateVideoThumbnail(file);
|
||||
thumbnailUrl = videoResult.thumbnailUrl;
|
||||
width = videoResult.width;
|
||||
height = videoResult.height;
|
||||
} else if (fileType === "audio") {
|
||||
// For audio, we don't set width/height/fps (they'll be undefined)
|
||||
// For audio, we don't set width/height (they'll be undefined)
|
||||
}
|
||||
|
||||
// Get duration for videos and audio (if not already set by FFmpeg)
|
||||
if ((fileType === "video" || fileType === "audio") && !duration) {
|
||||
duration = await getMediaDuration(file);
|
||||
}
|
||||
|
||||
@ -79,7 +64,6 @@ export async function processMediaFiles(
|
||||
duration,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
});
|
||||
|
||||
// Yield back to the event loop to keep the UI responsive
|
||||
|
@ -1,16 +1,14 @@
|
||||
// Time-related utility functions
|
||||
|
||||
// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
|
||||
// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS)
|
||||
export const formatTimeCode = (
|
||||
timeInSeconds: number,
|
||||
format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
|
||||
fps: number = 30
|
||||
format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" = "HH:MM:SS:CS"
|
||||
): string => {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
|
||||
const frames = Math.floor((timeInSeconds % 1) * fps);
|
||||
|
||||
switch (format) {
|
||||
case "MM:SS":
|
||||
@ -19,7 +17,5 @@ export const formatTimeCode = (
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS:CS":
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS:FF":
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
|
||||
}
|
||||
};
|
||||
|
@ -5,34 +5,4 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a UUID v4 string
|
||||
* Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
// Use the native crypto.randomUUID if available
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Secure fallback using crypto.getRandomValues
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Set version 4 (UUIDv4)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
// Set variant 10xxxxxx
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0'));
|
||||
|
||||
return (
|
||||
hex.slice(0, 4).join('') + '-' +
|
||||
hex.slice(4, 6).join('') + '-' +
|
||||
hex.slice(6, 8).join('') + '-' +
|
||||
hex.slice(8, 10).join('') + '-' +
|
||||
hex.slice(10, 16).join('')
|
||||
);
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
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";
|
||||
|
||||
@ -15,7 +14,6 @@ export interface MediaItem {
|
||||
duration?: number; // For video/audio duration
|
||||
width?: number; // For video/image width
|
||||
height?: number; // For video/image height
|
||||
fps?: number; // For video frame rate
|
||||
// Text-specific properties
|
||||
content?: string; // Text content
|
||||
fontSize?: number; // Font size
|
||||
@ -163,7 +161,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
addMediaItem: async (projectId, item) => {
|
||||
const newItem: MediaItem = {
|
||||
...item,
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
// Add to local state immediately for UI responsiveness
|
||||
|
@ -4,7 +4,6 @@ import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
@ -26,7 +25,6 @@ interface ProjectStore {
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: number }
|
||||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
@ -37,7 +35,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
@ -225,7 +223,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
thumbnail: project.thumbnail,
|
||||
createdAt: new Date(),
|
||||
@ -295,26 +293,4 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectFps: async (fps: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
fps,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
@ -13,7 +13,6 @@ import { useEditorStore } from "./editor-store";
|
||||
import { useMediaStore, getMediaAspectRatio } from "./media-store";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
// Helper function to manage element naming with suffixes
|
||||
const getElementNameWithSuffix = (
|
||||
@ -280,7 +279,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
: "Track";
|
||||
|
||||
const newTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
name: trackName,
|
||||
type,
|
||||
elements: [],
|
||||
@ -305,7 +304,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
: "Track";
|
||||
|
||||
const newTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
name: trackName,
|
||||
type,
|
||||
elements: [],
|
||||
@ -364,14 +363,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
const newElement: TimelineElement = {
|
||||
...elementData,
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
startTime: elementData.startTime || 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
} as TimelineElement; // Type assertion since we trust the caller passes valid data
|
||||
|
||||
// If this is the first element and it's a media element, automatically set the project canvas size
|
||||
// to match the media's aspect ratio and FPS (for videos)
|
||||
// to match the media's aspect ratio
|
||||
if (isFirstElement && newElement.type === "media") {
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const mediaItem = mediaStore.mediaItems.find(
|
||||
@ -387,14 +386,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
getMediaAspectRatio(mediaItem)
|
||||
);
|
||||
}
|
||||
|
||||
// Set project FPS from the first video element
|
||||
if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
|
||||
const projectStore = useProjectStore.getState();
|
||||
if (projectStore.activeProject) {
|
||||
projectStore.updateProjectFps(mediaItem.fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTracksAndSave(
|
||||
@ -565,7 +556,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
const secondDuration =
|
||||
element.duration - element.trimStart - element.trimEnd - relativeTime;
|
||||
|
||||
const secondElementId = generateUUID();
|
||||
const secondElementId = crypto.randomUUID();
|
||||
|
||||
updateTracksAndSave(
|
||||
get()._tracks.map((track) =>
|
||||
@ -691,7 +682,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
// Find existing audio track or prepare to create one
|
||||
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
|
||||
const audioElementId = generateUUID();
|
||||
const audioElementId = crypto.randomUUID();
|
||||
|
||||
if (existingAudioTrack) {
|
||||
// Add audio element to existing audio track
|
||||
@ -715,7 +706,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
} else {
|
||||
// Create new audio track with the audio element in a single atomic update
|
||||
const newAudioTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
name: "Audio Track",
|
||||
type: "audio",
|
||||
elements: [
|
||||
|
@ -8,5 +8,4 @@ export interface TProject {
|
||||
backgroundColor?: string;
|
||||
backgroundType?: "color" | "blur";
|
||||
blurIntensity?: number; // in pixels (4, 8, 18)
|
||||
fps?: number;
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { MediaType } from "@/stores/media-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export type TrackType = "media" | "text" | "audio";
|
||||
|
||||
@ -112,7 +111,7 @@ export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
|
||||
if (!hasMainTrack) {
|
||||
// Create main track if it doesn't exist
|
||||
const mainTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
name: "Main Track",
|
||||
type: "media",
|
||||
elements: [],
|
||||
|
14
bun.lock
14
bun.lock
@ -2,7 +2,6 @@
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "opencut",
|
||||
"dependencies": {
|
||||
"next": "^15.3.4",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
@ -22,6 +21,7 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/auth": "workspace:*",
|
||||
"@opencut/db": "workspace:*",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@upstash/redis": "^1.35.0",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
@ -39,7 +39,6 @@
|
||||
"motion": "^12.18.1",
|
||||
"next": "^15.3.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"ollama": "^0.5.16",
|
||||
"pg": "^8.16.2",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^18.2.0",
|
||||
@ -59,7 +58,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"cross-env": "^7.0.3",
|
||||
@ -105,8 +103,6 @@
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="],
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250701.0", "", {}, "sha512-q1bHwe5P7FGy9RkLYOY1kwoZrqUe2Q6XhCPscaxzQc0N7+2pwIZzZzY5iMTTfvmf65UNsadoVxuF+vPVXoAkkQ=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
@ -415,7 +411,7 @@
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
|
||||
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
|
||||
|
||||
@ -693,8 +689,6 @@
|
||||
|
||||
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"ollama": ["ollama@0.5.16", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w=="],
|
||||
|
||||
"opencut": ["opencut@workspace:apps/web"],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
@ -911,8 +905,6 @@
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
|
||||
|
||||
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
@ -929,8 +921,6 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
@ -52,7 +52,7 @@ services:
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3100:3000" # app is running on 3000 so we run this at 3100
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://opencut:opencutthegoat@db:5432/opencut
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencut",
|
||||
"packageManager": "bun@1.2.18",
|
||||
"packageManager": "bun@1.2.17",
|
||||
"devDependencies": {
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
|
@ -7,7 +7,7 @@ export const auth = betterAuth({
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
|
@ -19,7 +19,11 @@ function getDb() {
|
||||
}
|
||||
|
||||
// Export a proxy that forwards all calls to the actual db instance
|
||||
export const db = getDb();
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
get(target, prop) {
|
||||
return getDb()[prop as keyof typeof _db];
|
||||
},
|
||||
});
|
||||
|
||||
// Re-export schema for convenience
|
||||
export * from "./schema";
|
||||
|
@ -5,7 +5,7 @@ export const users = pgTable("users", {
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified")
|
||||
.default(false)
|
||||
.$defaultFn(() => false)
|
||||
.notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at")
|
||||
|
Reference in New Issue
Block a user