1 Commits

Author SHA1 Message Date
2831c75982 docs: add growth to README 2025-07-11 15:59:33 +02:00
34 changed files with 177 additions and 597 deletions

View File

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

View File

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

@ -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
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
[![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)]

View File

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

View File

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

View File

@ -82,7 +82,5 @@
}
body {
@apply bg-background text-foreground;
/* Prevent back/forward swipe */
overscroll-behavior-x: contain;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,5 +8,4 @@ export interface TProject {
backgroundColor?: string;
backgroundType?: "color" | "blur";
blurIntensity?: number; // in pixels (4, 8, 18)
fps?: number;
}

View File

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

View File

@ -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=="],

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "opencut",
"packageManager": "bun@1.2.18",
"packageManager": "bun@1.2.17",
"devDependencies": {
"turbo": "^2.5.4"
},

View File

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

View File

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

View File

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