Merge branch 'main' into split-issue-83
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,31 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,70 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
body:
|
||||
- type: input
|
||||
id: Platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Please enter the platform on which you encountered the bug.
|
||||
placeholder: e.g. Windows 11, Ubuntu 14.04
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: Browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: Please enter the browser on which you encountered the bug.
|
||||
placeholder: e.g. Chrome 137, Firefox 137, Safari 17
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-behavior
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: recurrence-probability
|
||||
attributes:
|
||||
label: Recurrence Probability
|
||||
description: How often does this bug occur?
|
||||
options:
|
||||
- Always
|
||||
- Usually
|
||||
- Sometimes
|
||||
- Seldom
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,19 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for OpenCut
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Please make sure that no duplicated issues has already been delivered.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
placeholder: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Solution
|
||||
placeholder: Describe the solution you'd like.
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: Alternative
|
||||
placeholder: Describe alternatives you've considered.
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
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.2
|
||||
bun-version: 1.2.17
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/web
|
||||
|
6
.gitignore
vendored
@ -27,4 +27,8 @@ node_modules
|
||||
.cursorignore
|
||||
.turbo
|
||||
|
||||
*.env
|
||||
*.env
|
||||
|
||||
# cursor
|
||||
|
||||
.cursor/
|
15
README.md
@ -59,7 +59,16 @@ Before you begin, ensure you have the following installed on your system:
|
||||
Navigate into the web app's directory and create a `.env` file from the example:
|
||||
```bash
|
||||
cd apps/web
|
||||
cp .env.example .env
|
||||
|
||||
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
*The default values in the `.env` file should work for local development.*
|
||||
|
||||
@ -94,13 +103,13 @@ Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
=======
|
||||
---
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
=======
|
||||
---
|
||||
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||
|
||||
Quick start for contributors:
|
||||
|
@ -19,7 +19,8 @@ COPY apps/web/ apps/web/
|
||||
COPY packages/db/ packages/db/
|
||||
COPY packages/auth/ packages/auth/
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN bun run build
|
||||
@ -28,7 +29,8 @@ RUN bun run build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
11
apps/web/public/browserconfig.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="/icons/ms-icon-70x70.png"/>
|
||||
<square150x150logo src="/icons/ms-icon-150x150.png"/>
|
||||
<square310x310logo src="/icons/ms-icon-310x310.png"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
apps/web/public/favicon.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
10
apps/web/public/frame.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="459" height="77" viewBox="0 0 459 77" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" fill="#101010"/>
|
||||
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" stroke="#FFCC00"/>
|
||||
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
|
||||
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
|
||||
<rect x="13" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
|
||||
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
|
||||
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
|
||||
<rect x="440" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
|
||||
</svg>
|
After Width: | Height: | Size: 716 B |
BIN
apps/web/public/icons/android-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/android-icon-192x192.png
Normal file
After Width: | Height: | Size: 741 B |
BIN
apps/web/public/icons/android-icon-36x36.png
Normal file
After Width: | Height: | Size: 768 B |
BIN
apps/web/public/icons/android-icon-48x48.png
Normal file
After Width: | Height: | Size: 802 B |
BIN
apps/web/public/icons/android-icon-72x72.png
Normal file
After Width: | Height: | Size: 826 B |
BIN
apps/web/public/icons/android-icon-96x96.png
Normal file
After Width: | Height: | Size: 906 B |
BIN
apps/web/public/icons/apple-icon-114x114.png
Normal file
After Width: | Height: | Size: 985 B |
BIN
apps/web/public/icons/apple-icon-120x120.png
Normal file
After Width: | Height: | Size: 998 B |
BIN
apps/web/public/icons/apple-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/apple-icon-152x152.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/apple-icon-180x180.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/icons/apple-icon-57x57.png
Normal file
After Width: | Height: | Size: 809 B |
BIN
apps/web/public/icons/apple-icon-60x60.png
Normal file
After Width: | Height: | Size: 843 B |
BIN
apps/web/public/icons/apple-icon-72x72.png
Normal file
After Width: | Height: | Size: 826 B |
BIN
apps/web/public/icons/apple-icon-76x76.png
Normal file
After Width: | Height: | Size: 820 B |
BIN
apps/web/public/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 670 B |
BIN
apps/web/public/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 747 B |
BIN
apps/web/public/icons/favicon-96x96.png
Normal file
After Width: | Height: | Size: 906 B |
BIN
apps/web/public/icons/ms-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/ms-icon-150x150.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/ms-icon-310x310.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
apps/web/public/icons/ms-icon-70x70.png
Normal file
After Width: | Height: | Size: 814 B |
BIN
apps/web/public/landing-page-bg.png
Normal file
After Width: | Height: | Size: 225 KiB |
10
apps/web/public/logo.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10_2)">
|
||||
<path d="M32 9.37305V22.627L22.627 32H9.37305L0 22.627V9.37305L9.37305 0H22.627L32 9.37305ZM8 8V24H24V8H8Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10_2">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 362 B |
44
apps/web/public/manifest.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "OpenCut",
|
||||
"description": "A simple but powerful video editor that gets the job done. In your browser.",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GithubIcon } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contributors - OpenCut",
|
||||
@ -49,7 +50,7 @@ async function getContributors(): Promise<Contributor[]> {
|
||||
const contributors = await response.json();
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor: any) => contributor.type === "User"
|
||||
(contributor: Contributor) => contributor.type === "User"
|
||||
);
|
||||
|
||||
return filteredContributors;
|
||||
@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
|
||||
|
||||
export default async function ContributorsPage() {
|
||||
const contributors = await getContributors();
|
||||
const topContributor = contributors[0];
|
||||
const otherContributors = contributors.slice(1);
|
||||
const topContributors = contributors.slice(0, 2);
|
||||
const otherContributors = contributors.slice(2);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@ -77,10 +78,10 @@ export default async function ContributorsPage() {
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</div>
|
||||
</Badge>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Contributors
|
||||
</h1>
|
||||
@ -105,54 +106,59 @@ export default async function ContributorsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topContributor && (
|
||||
{topContributors.length > 0 && (
|
||||
<div className="mb-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-2">
|
||||
Top Contributor
|
||||
Top Contributors
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Leading the way in contributions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={topContributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block"
|
||||
>
|
||||
<div className="relative mx-auto max-w-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="relative mb-6">
|
||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={topContributor.avatar_url}
|
||||
alt={`${topContributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{topContributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{topContributor.login}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{topContributor.contributions}
|
||||
</span>
|
||||
<span>contributions</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
|
||||
{topContributors.map((contributor, index) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block flex-1"
|
||||
>
|
||||
<div className="relative mx-auto max-w-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="relative mb-6">
|
||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{contributor.login}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{contributor.contributions}
|
||||
</span>
|
||||
<span>contributions</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -167,7 +173,7 @@ export default async function ContributorsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
{otherContributors.map((contributor, index) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
@ -179,8 +185,8 @@ export default async function ContributorsPage() {
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
|
||||
<Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
|
||||
<div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
|
||||
<Avatar className="h-16 w-16 mx-auto mb-3">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -39,7 +39,7 @@
|
||||
--sidebar-ring: 0 0% 3.9%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 8%;
|
||||
--background: 0 0% 6%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
@ -1,4 +1,3 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
@ -6,46 +5,15 @@ import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { DevelopmentDebug } from "../components/development-debug";
|
||||
import { baseMetaData } from "./metadata";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
openGraph: {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
url: "https://opencut.app",
|
||||
siteName: "OpenCut",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "https://opencut.app/opengraph-image.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
creator: "@opencutapp",
|
||||
images: ["/opengraph-image.jpg"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
export const metadata = baseMetaData;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@ -60,6 +28,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<DevelopmentDebug />
|
||||
<Script
|
||||
src="https://app.databuddy.cc/databuddy.js"
|
||||
strategy="afterInteractive"
|
||||
|
66
apps/web/src/app/metadata.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
const title = "OpenCut";
|
||||
const description = "A simple but powerful video editor that gets the job done. In your browser.";
|
||||
const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
|
||||
const twitterImageUrl = "/opengraph-image.jpg";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
title: title,
|
||||
description: description,
|
||||
openGraph: {
|
||||
title: title,
|
||||
description: description,
|
||||
url: "https://opencut.app",
|
||||
siteName: "OpenCut",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: openGraphImageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: title,
|
||||
description: description,
|
||||
creator: "@opencutapp",
|
||||
images: [twitterImageUrl],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico" },
|
||||
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
|
||||
],
|
||||
shortcut: ["/favicon.ico"]
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: title,
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
other: {
|
||||
"msapplication-config": "/browserconfig.xml"
|
||||
}
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { getWaitlistCount } from "@/lib/waitlist";
|
||||
import Image from "next/image";
|
||||
|
||||
// Force dynamic rendering so waitlist count updates in real-time
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -10,6 +11,13 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Image
|
||||
className="fixed top-0 left-0 -z-50 size-full object-cover"
|
||||
src="/landing-page-bg.png"
|
||||
height={1903.5}
|
||||
width={1269}
|
||||
alt="landing-page.bg"
|
||||
/>
|
||||
<Header />
|
||||
<Hero signupCount={signupCount} />
|
||||
</div>
|
||||
|
107
apps/web/src/components/development-debug.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useTimelineStore,
|
||||
type TimelineClip,
|
||||
type TimelineTrack,
|
||||
} from "@/stores/timeline-store";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
|
||||
// Only show in development
|
||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
|
||||
|
||||
interface ActiveClip {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
mediaItem: MediaItem | null;
|
||||
}
|
||||
|
||||
export function DevelopmentDebug() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
|
||||
// Don't render anything in production
|
||||
if (!SHOW_DEBUG_INFO) return null;
|
||||
|
||||
// Get active clips at current time
|
||||
const getActiveClips = (): ActiveClip[] => {
|
||||
const activeClips: ActiveClip[] = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem =
|
||||
clip.mediaId === "test"
|
||||
? null // Test clips don't have a real media item
|
||||
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
||||
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeClips;
|
||||
};
|
||||
|
||||
const activeClips = getActiveClips();
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{/* Toggle Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="text-xs backdrop-blur-md bg-background/80 border-border/50"
|
||||
>
|
||||
Debug {showDebug ? "ON" : "OFF"}
|
||||
</Button>
|
||||
|
||||
{/* Debug Info Panel */}
|
||||
{showDebug && (
|
||||
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
|
||||
<div className="text-xs font-medium mb-2 text-foreground">
|
||||
Active Clips ({activeClips.length})
|
||||
</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{activeClips.map((clipData, index) => (
|
||||
<div
|
||||
key={clipData.clip.id}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
|
||||
>
|
||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{clipData.clip.name}</div>
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{clipData.mediaItem?.type || "test"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeClips.length === 0 && (
|
||||
<div className="text-muted-foreground text-xs py-2 text-center">
|
||||
No active clips
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
|
||||
Time: {currentTime.toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -3,13 +3,11 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "./ui/button";
|
||||
import { ChevronLeft, Download } from "lucide-react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { ProjectNameEditor } from "./editor/project-name-editor";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { activeProject } = useProjectStore();
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
|
||||
const handleExport = () => {
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { AspectRatio } from "../ui/aspect-ratio";
|
||||
import { DragOverlay } from "../ui/drag-overlay";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
@ -17,27 +17,28 @@ export function MediaPanel() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
// If no files, do nothing
|
||||
if (!files?.length) return;
|
||||
|
||||
if (!files || files.length === 0) return;
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Process files (extract metadata, generate thumbnails, etc.)
|
||||
const items = await processMediaFiles(files);
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
// Add each processed media item to the store
|
||||
items.forEach((item) => {
|
||||
addMediaItem(item);
|
||||
});
|
||||
processedItems.forEach((item) => addMediaItem(item));
|
||||
} catch (error) {
|
||||
// Show error if processing fails
|
||||
console.error("File processing failed:", error);
|
||||
// Show error toast if processing fails
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@ -67,7 +68,7 @@ export function MediaPanel() {
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const startDrag = (e: React.DragEvent, item: any) => {
|
||||
const startDrag = (e: React.DragEvent, item: MediaItem) => {
|
||||
// When dragging a media item, set drag data for timeline to read
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-item",
|
||||
@ -101,7 +102,7 @@ export function MediaPanel() {
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
|
||||
const renderPreview = (item: any) => {
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
// Each preview is draggable to the timeline
|
||||
const baseDragProps = {
|
||||
@ -241,15 +242,12 @@ export function MediaPanel() {
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 animate-spin" />
|
||||
<span className="hidden md:inline ml-2">Processing...</span>
|
||||
<span className="hidden md:inline ml-2">{progress}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span
|
||||
className="hidden sm:inline ml-2"
|
||||
aria-label="Add file"
|
||||
>
|
||||
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||
Add
|
||||
</span>
|
||||
</>
|
||||
|
@ -1,246 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pause, Volume2, VolumeX } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
// Debug flag - set to false to hide active clips info
|
||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
|
||||
|
||||
export function PreviewPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
|
||||
usePlaybackStore();
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get active clips at current time
|
||||
const getActiveClips = () => {
|
||||
const activeClips: Array<{
|
||||
clip: any;
|
||||
track: any;
|
||||
mediaItem: any;
|
||||
}> = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem =
|
||||
clip.mediaId === "test"
|
||||
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
|
||||
: mediaItems.find((item) => item.id === clip.mediaId);
|
||||
|
||||
if (mediaItem || clip.mediaId === "test") {
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeClips;
|
||||
};
|
||||
|
||||
const activeClips = getActiveClips();
|
||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||
|
||||
// Render a clip
|
||||
const renderClip = (clipData: any, index: number) => {
|
||||
const { clip, mediaItem } = clipData;
|
||||
|
||||
// Test clips
|
||||
if (!mediaItem || clip.mediaId === "test") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{clip.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video clips
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<VideoPlayer
|
||||
src={mediaItem.url}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={clip.startTime}
|
||||
trimStart={clip.trimStart}
|
||||
trimEnd={clip.trimEnd}
|
||||
clipDuration={clip.duration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image clips
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio clips (visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎵</div>
|
||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas presets
|
||||
const canvasPresets = [
|
||||
{ name: "16:9 HD", width: 1920, height: 1080 },
|
||||
{ name: "16:9 4K", width: 3840, height: 2160 },
|
||||
{ name: "9:16 Mobile", width: 1080, height: 1920 },
|
||||
{ name: "1:1 Square", width: 1080, height: 1080 },
|
||||
{ name: "4:3 Standard", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
|
||||
{/* Controls */}
|
||||
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
|
||||
<span className="text-muted-foreground">Canvas:</span>
|
||||
<select
|
||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
||||
onChange={(e) => {
|
||||
const preset = canvasPresets.find(
|
||||
(p) => `${p.width}x${p.height}` === e.target.value
|
||||
);
|
||||
if (preset)
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
}}
|
||||
className="bg-background border rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{canvasPresets.map((preset) => (
|
||||
<option
|
||||
key={preset.name}
|
||||
value={`${preset.width}x${preset.height}`}
|
||||
>
|
||||
{preset.name} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Debug Toggle - Only show in development */}
|
||||
{SHOW_DEBUG_INFO && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="text-xs"
|
||||
>
|
||||
Debug {showDebug ? "ON" : "OFF"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleMute}
|
||||
className="ml-auto"
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<VolumeX className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<Volume2 className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{muted || volume === 0 ? "Unmute" : "Mute"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={toggle}>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{isPlaying ? "Pause" : "Play"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
|
||||
style={{
|
||||
aspectRatio: aspectRatio.toString(),
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{activeClips.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
||||
{tracks.length === 0
|
||||
? "Drop media to start editing"
|
||||
: "No clips at current time"}
|
||||
</div>
|
||||
) : (
|
||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info Panel - Conditionally rendered */}
|
||||
{showDebug && (
|
||||
<div className="border-t bg-background p-2 flex-shrink-0">
|
||||
<div className="text-xs font-medium mb-1">
|
||||
Debug: Active Clips ({activeClips.length})
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{activeClips.map((clipData, index) => (
|
||||
<div
|
||||
key={clipData.clip.id}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
|
||||
>
|
||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{clipData.clip.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({clipData.mediaItem?.type || "test"})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{activeClips.length === 0 && (
|
||||
<span className="text-muted-foreground">No active clips</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useTimelineStore,
|
||||
type TimelineClip,
|
||||
type TimelineTrack,
|
||||
} from "@/stores/timeline-store";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
interface ActiveClip {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
mediaItem: MediaItem | null;
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [previewDimensions, setPreviewDimensions] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
// Calculate optimal preview size that fits in container while maintaining aspect ratio
|
||||
useEffect(() => {
|
||||
const updatePreviewSize = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current.getBoundingClientRect();
|
||||
const computedStyle = getComputedStyle(containerRef.current);
|
||||
|
||||
// Get padding values
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop);
|
||||
const paddingBottom = parseFloat(computedStyle.paddingBottom);
|
||||
const paddingLeft = parseFloat(computedStyle.paddingLeft);
|
||||
const paddingRight = parseFloat(computedStyle.paddingRight);
|
||||
|
||||
// Get gap value (gap-4 = 1rem = 16px)
|
||||
const gap = parseFloat(computedStyle.gap) || 16;
|
||||
|
||||
// Get toolbar height if it exists
|
||||
const toolbar = containerRef.current.querySelector("[data-toolbar]");
|
||||
const toolbarHeight = toolbar
|
||||
? toolbar.getBoundingClientRect().height
|
||||
: 0;
|
||||
|
||||
// Calculate available space after accounting for padding, gap, and toolbar
|
||||
const availableWidth = container.width - paddingLeft - paddingRight;
|
||||
const availableHeight =
|
||||
container.height -
|
||||
paddingTop -
|
||||
paddingBottom -
|
||||
toolbarHeight -
|
||||
(toolbarHeight > 0 ? gap : 0);
|
||||
|
||||
const targetRatio = canvasSize.width / canvasSize.height;
|
||||
const containerRatio = availableWidth / availableHeight;
|
||||
|
||||
let width, height;
|
||||
|
||||
if (containerRatio > targetRatio) {
|
||||
// Container is wider - constrain by height
|
||||
height = availableHeight;
|
||||
width = height * targetRatio;
|
||||
} else {
|
||||
// Container is taller - constrain by width
|
||||
width = availableWidth;
|
||||
height = width / targetRatio;
|
||||
}
|
||||
|
||||
setPreviewDimensions({ width, height });
|
||||
};
|
||||
|
||||
updatePreviewSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updatePreviewSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [canvasSize.width, canvasSize.height]);
|
||||
|
||||
// Get active clips at current time
|
||||
const getActiveClips = (): ActiveClip[] => {
|
||||
const activeClips: ActiveClip[] = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem =
|
||||
clip.mediaId === "test"
|
||||
? null // Test clips don't have a real media item
|
||||
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
||||
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeClips;
|
||||
};
|
||||
|
||||
const activeClips = getActiveClips();
|
||||
|
||||
// Render a clip
|
||||
const renderClip = (clipData: ActiveClip, index: number) => {
|
||||
const { clip, mediaItem } = clipData;
|
||||
|
||||
// Test clips
|
||||
if (!mediaItem || clip.mediaId === "test") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{clip.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video clips
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<VideoPlayer
|
||||
src={mediaItem.url}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={clip.startTime}
|
||||
trimStart={clip.trimStart}
|
||||
trimEnd={clip.trimEnd}
|
||||
clipDuration={clip.duration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image clips
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio clips (visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎵</div>
|
||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas presets
|
||||
const canvasPresets = [
|
||||
{ name: "16:9 HD", width: 1920, height: 1080 },
|
||||
{ name: "16:9 4K", width: 3840, height: 2160 },
|
||||
{ name: "9:16 Mobile", width: 1080, height: 1920 },
|
||||
{ name: "1:1 Square", width: 1080, height: 1080 },
|
||||
{ name: "4:3 Standard", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
|
||||
{/* Controls */}
|
||||
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
|
||||
<span className="text-muted-foreground">Canvas:</span>
|
||||
<select
|
||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
||||
onChange={(e) => {
|
||||
const preset = canvasPresets.find(
|
||||
(p) => `${p.width}x${p.height}` === e.target.value
|
||||
);
|
||||
if (preset)
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
}}
|
||||
className="bg-background border rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{canvasPresets.map((preset) => (
|
||||
<option
|
||||
key={preset.name}
|
||||
value={`${preset.width}x${preset.height}`}
|
||||
>
|
||||
{preset.name} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleMute}
|
||||
className="ml-auto"
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<VolumeX className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<Volume2 className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{muted || volume === 0 ? "Unmute" : "Mute"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
|
||||
>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm bg-black border"
|
||||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
}}
|
||||
>
|
||||
{activeClips.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
{tracks.length === 0
|
||||
? "Drop media to start editing"
|
||||
: "No clips at current time"}
|
||||
</div>
|
||||
) : (
|
||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviewToolbar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbar() {
|
||||
const { isPlaying, toggle } = usePlaybackStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-toolbar
|
||||
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
|
||||
>
|
||||
<Button variant="text" size="icon" onClick={toggle}>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -17,13 +17,12 @@ import { useMediaStore } from "@/stores/media-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { useState } from "react";
|
||||
import { SpeedControl } from "./speed-control";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const [backgroundType, setBackgroundType] = useState<
|
||||
"blur" | "mirror" | "color"
|
||||
>("blur");
|
||||
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
|
||||
// Get the first video clip for preview (simplified)
|
||||
@ -78,7 +77,9 @@ export function PropertiesPanel() {
|
||||
<Label htmlFor="bg-type">Background Type</Label>
|
||||
<Select
|
||||
value={backgroundType}
|
||||
onValueChange={(value: any) => setBackgroundType(value)}
|
||||
onValueChange={(value: BackgroundType) =>
|
||||
setBackgroundType(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select background type" />
|
||||
|
276
apps/web/src/components/editor/timeline-clip.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { MoreVertical, Scissors, Trash2 } from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useDragClip } from "@/hooks/use-drag-clip";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { toast } from "sonner";
|
||||
import { TimelineClipProps, ResizeState } from "@/types/timeline";
|
||||
|
||||
export function TimelineClip({
|
||||
clip,
|
||||
track,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
onContextMenu,
|
||||
onClipMouseDown,
|
||||
onClipClick,
|
||||
}: TimelineClipProps) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } =
|
||||
useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [clipMenuOpen, setClipMenuOpen] = useState(false);
|
||||
|
||||
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
|
||||
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
|
||||
|
||||
// Use real-time position during drag, otherwise use stored position
|
||||
const isBeingDragged = dragState.clipId === clip.id;
|
||||
const clipStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: clip.startTime;
|
||||
const clipLeft = clipStartTime * 50 * zoomLevel;
|
||||
|
||||
const getTrackColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "video":
|
||||
return "bg-blue-500/20 border-blue-500/30";
|
||||
case "audio":
|
||||
return "bg-green-500/20 border-green-500/30";
|
||||
case "effects":
|
||||
return "bg-purple-500/20 border-purple-500/30";
|
||||
default:
|
||||
return "bg-gray-500/20 border-gray-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
clipId: string,
|
||||
side: "left" | "right"
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setResizing({
|
||||
clipId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: clip.trimStart,
|
||||
initialTrimEnd: clip.trimEnd,
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = (e: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = e.clientX - resizing.startX;
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const newTrimStart = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
clip.duration - clip.trimEnd - 0.1,
|
||||
resizing.initialTrimStart + deltaTime
|
||||
)
|
||||
);
|
||||
updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
|
||||
} else {
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
clip.duration - clip.trimStart - 0.1,
|
||||
resizing.initialTrimEnd - deltaTime
|
||||
)
|
||||
);
|
||||
updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = (e: React.MouseEvent) => {
|
||||
updateTrimFromMouseMove(e);
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
const handleDeleteClip = () => {
|
||||
removeClipFromTrack(track.id, clip.id);
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitClip = () => {
|
||||
// Use current playback time as split point
|
||||
const splitTime = currentTime;
|
||||
// Only split if splitTime is within the clip's effective range
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within clip to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstDuration = splitTime - effectiveStart;
|
||||
const secondDuration = effectiveEnd - splitTime;
|
||||
|
||||
// First part: adjust original clip
|
||||
updateClipTrim(
|
||||
track.id,
|
||||
clip.id,
|
||||
clip.trimStart,
|
||||
clip.trimEnd + secondDuration
|
||||
);
|
||||
|
||||
// Second part: add new clip after split
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (cut)",
|
||||
duration: clip.duration,
|
||||
startTime: splitTime,
|
||||
trimStart: clip.trimStart + firstDuration,
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
|
||||
setClipMenuOpen(false);
|
||||
toast.success("Clip split successfully");
|
||||
};
|
||||
|
||||
const renderClipContent = () => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
|
||||
if (!mediaItem) {
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="w-8 h-8 flex-shrink-0">
|
||||
<img
|
||||
src={mediaItem.thumbnailUrl}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-foreground/80 truncate flex-1">
|
||||
{clip.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<AudioWaveform
|
||||
audioUrl={mediaItem.url}
|
||||
height={24}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for videos without thumbnails
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""}`}
|
||||
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||
onMouseDown={(e) => onClipMouseDown(e, clip)}
|
||||
onClick={(e) => onClipClick(e, clip)}
|
||||
onMouseMove={handleResizeMove}
|
||||
onMouseUp={handleResizeEnd}
|
||||
onMouseLeave={handleResizeEnd}
|
||||
tabIndex={0}
|
||||
onContextMenu={(e) => onContextMenu(e, clip.id)}
|
||||
>
|
||||
{/* Left trim handle */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
|
||||
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
|
||||
/>
|
||||
|
||||
{/* Clip content */}
|
||||
<div className="flex-1 relative">
|
||||
{renderClipContent()}
|
||||
|
||||
{/* Clip options menu */}
|
||||
<div className="absolute top-1 right-1 z-10">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setClipMenuOpen(!clipMenuOpen);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{clipMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
|
||||
onClick={handleSplitClip}
|
||||
>
|
||||
<Scissors className="h-4 w-4 mr-2" /> Split
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
onClick={handleDeleteClip}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right trim handle */}
|
||||
<div
|
||||
className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
|
||||
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1860,3 +1860,4 @@ function TimelineTrackContent({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -29,7 +29,7 @@ export function HeaderBase({
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn("px-6 h-16 flex justify-between items-center", className)}
|
||||
className={cn("px-6 h-14 flex justify-between items-center", className)}
|
||||
>
|
||||
{leftContent && <div className="flex items-center">{leftContent}</div>}
|
||||
{centerContent && (
|
||||
|
@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { useSession } from "@opencut/auth/client";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import { Star } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
@ -29,15 +28,15 @@ export function Header() {
|
||||
|
||||
const leftContent = (
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
||||
<span className="font-medium tracking-tight">OpenCut</span>
|
||||
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
|
||||
<span className="text-xl font-medium hidden md:block">OpenCut</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center">
|
||||
<nav className="flex items-center gap-3">
|
||||
<Link href="/contributors">
|
||||
<Button variant="text" className="text-sm">
|
||||
<Button variant="text" className="text-sm p-0">
|
||||
Contributors
|
||||
</Button>
|
||||
</Link>
|
||||
@ -59,5 +58,13 @@ export function Header() {
|
||||
</nav>
|
||||
);
|
||||
|
||||
return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
|
||||
return (
|
||||
<div className="mx-4 md:mx-0">
|
||||
<HeaderBase
|
||||
className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
|
||||
leftContent={leftContent}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
|
||||
viewBox="0 -3.5 256 256"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
>
|
||||
<g fill="#161614">
|
||||
<g fill="currentColor">
|
||||
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
|
||||
|
||||
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />
|
||||
|
@ -8,6 +8,7 @@ import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import Image from "next/image";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@ -93,18 +94,21 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="inline-block"
|
||||
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||
>
|
||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
|
||||
The open source
|
||||
</h1>
|
||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
|
||||
video editor
|
||||
</h1>
|
||||
<h1>The Open Source</h1>
|
||||
<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
|
||||
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.8 }}
|
||||
@ -119,7 +123,10 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
|
@ -2,13 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
interface ImageTimelineTreatmentProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
targetAspectRatio?: number; // Default to 16:9 for video
|
||||
className?: string;
|
||||
backgroundType?: "blur" | "mirror" | "color";
|
||||
backgroundType?: BackgroundType;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
|
226
apps/web/src/hooks/use-drag-clip.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
interface DragState {
|
||||
isDragging: boolean;
|
||||
clipId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startClipTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
export function useDragClip(zoomLevel: number) {
|
||||
const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore();
|
||||
|
||||
const [dragState, setDragState] = useState<DragState>({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const dragStateRef = useRef(dragState);
|
||||
|
||||
// Keep ref in sync with state
|
||||
dragStateRef.current = dragState;
|
||||
|
||||
const startDrag = useCallback(
|
||||
(
|
||||
e: React.MouseEvent,
|
||||
clipId: string,
|
||||
trackId: string,
|
||||
clipStartTime: number,
|
||||
clickOffsetTime: number
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
clipId,
|
||||
trackId,
|
||||
startMouseX: e.clientX,
|
||||
startClipTime: clipStartTime,
|
||||
clickOffsetTime,
|
||||
currentTime: clipStartTime,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateDrag = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!dragState.isDragging || !timelineRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timelineRect = timelineRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - timelineRect.left;
|
||||
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
const snappedTime = Math.round(adjustedTime * 10) / 10;
|
||||
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
currentTime: snappedTime,
|
||||
}));
|
||||
},
|
||||
[dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(targetTrackId?: string) => {
|
||||
if (!dragState.isDragging || !dragState.clipId || !dragState.trackId)
|
||||
return;
|
||||
|
||||
const finalTrackId = targetTrackId || dragState.trackId;
|
||||
const finalTime = dragState.currentTime;
|
||||
|
||||
// Check for overlaps
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const targetTrack = tracks.find((t) => t.id === finalTrackId);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c) => c.id === dragState.clipId
|
||||
);
|
||||
|
||||
if (!movingClip || !targetTrack) {
|
||||
setDragState((prev) => ({ ...prev, isDragging: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalTime + movingClipDuration;
|
||||
|
||||
const hasOverlap = targetTrack.clips.some((existingClip) => {
|
||||
// Skip the clip being moved if it's on the same track
|
||||
if (
|
||||
dragState.trackId === finalTrackId &&
|
||||
existingClip.id === dragState.clipId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
return finalTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
if (dragState.trackId === finalTrackId) {
|
||||
// Moving within same track
|
||||
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
},
|
||||
[dragState, tracks, updateClipStartTime, moveClipToTrack]
|
||||
);
|
||||
|
||||
const cancelDrag = useCallback(() => {
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Global mouse events
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => updateDrag(e);
|
||||
const handleMouseUp = () => endDrag();
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") cancelDrag();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
|
||||
|
||||
const getDraggedClipPosition = useCallback(
|
||||
(clipId: string) => {
|
||||
// Use ref to get current state, not stale closure
|
||||
const currentDragState = dragStateRef.current;
|
||||
const isMatch =
|
||||
currentDragState.isDragging && currentDragState.clipId === clipId;
|
||||
|
||||
if (isMatch) {
|
||||
return currentDragState.currentTime;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[] // No dependencies needed since we use ref
|
||||
);
|
||||
|
||||
const isValidDropTarget = useCallback(
|
||||
(trackId: string) => {
|
||||
if (!dragState.isDragging) return false;
|
||||
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const targetTrack = tracks.find((t) => t.id === trackId);
|
||||
|
||||
if (!sourceTrack || !targetTrack) return false;
|
||||
|
||||
// For now, allow drops on same track type
|
||||
return sourceTrack.type === targetTrack.type;
|
||||
},
|
||||
[dragState.isDragging, dragState.trackId, tracks]
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
isDragging: dragState.isDragging,
|
||||
draggedClipId: dragState.clipId,
|
||||
currentDragTime: dragState.currentTime,
|
||||
clickOffsetTime: dragState.clickOffsetTime,
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
endDrag,
|
||||
cancelDrag,
|
||||
getDraggedClipPosition,
|
||||
isValidDropTarget,
|
||||
|
||||
// Refs
|
||||
timelineRef,
|
||||
};
|
||||
}
|
@ -11,11 +11,15 @@ import {
|
||||
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
||||
|
||||
export async function processMediaFiles(
|
||||
files: FileList | File[]
|
||||
files: FileList | File[],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ProcessedMediaItem[]> {
|
||||
const fileArray = Array.from(files);
|
||||
const processedItems: ProcessedMediaItem[] = [];
|
||||
|
||||
const total = fileArray.length;
|
||||
let completed = 0;
|
||||
|
||||
for (const file of fileArray) {
|
||||
const fileType = getFileType(file);
|
||||
|
||||
@ -57,6 +61,15 @@ export async function processMediaFiles(
|
||||
duration,
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
// Yield back to the event loop to keep the UI responsive
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
completed += 1;
|
||||
if (onProgress) {
|
||||
const percent = Math.round((completed / total) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing file:", file.name, error);
|
||||
toast.error(`Failed to process ${file.name}`);
|
||||
|
@ -8,7 +8,7 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
|
||||
|
||||
let playbackTimer: number | null = null;
|
||||
|
||||
const startTimer = (store: any) => {
|
||||
const startTimer = (store: () => PlaybackStore) => {
|
||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
|
@ -1,264 +1,346 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface TimelineClip {
|
||||
id: string;
|
||||
mediaId: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
export interface TimelineTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "video" | "audio" | "effects";
|
||||
clips: TimelineClip[];
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineStore {
|
||||
tracks: TimelineTrack[];
|
||||
history: TimelineTrack[][];
|
||||
redoStack: TimelineTrack[][];
|
||||
|
||||
// Multi-selection
|
||||
selectedClips: { trackId: string; clipId: string }[];
|
||||
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
|
||||
deselectClip: (trackId: string, clipId: string) => void;
|
||||
clearSelectedClips: () => void;
|
||||
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
||||
|
||||
// Actions
|
||||
addTrack: (type: "video" | "audio" | "effects") => string;
|
||||
removeTrack: (trackId: string) => void;
|
||||
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
|
||||
removeClipFromTrack: (trackId: string, clipId: string) => void;
|
||||
moveClipToTrack: (
|
||||
fromTrackId: string,
|
||||
toTrackId: string,
|
||||
clipId: string
|
||||
) => void;
|
||||
updateClipTrim: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
trimStart: number,
|
||||
trimEnd: number
|
||||
) => void;
|
||||
updateClipStartTime: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
startTime: number
|
||||
) => void;
|
||||
toggleTrackMute: (trackId: string) => void;
|
||||
|
||||
// Computed values
|
||||
getTotalDuration: () => number;
|
||||
|
||||
// New actions
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
pushHistory: () => void;
|
||||
}
|
||||
|
||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
||||
tracks: [],
|
||||
history: [],
|
||||
redoStack: [],
|
||||
selectedClips: [],
|
||||
|
||||
pushHistory: () => {
|
||||
const { tracks, history, redoStack } = get();
|
||||
// Deep copy tracks
|
||||
set({
|
||||
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
||||
redoStack: [] // Clear redo stack when new action is performed
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { history, redoStack, tracks } = get();
|
||||
if (history.length === 0) return;
|
||||
const prev = history[history.length - 1];
|
||||
set({
|
||||
tracks: prev,
|
||||
history: history.slice(0, -1),
|
||||
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
|
||||
});
|
||||
},
|
||||
|
||||
selectClip: (trackId, clipId, multi = false) => {
|
||||
set((state) => {
|
||||
const exists = state.selectedClips.some(
|
||||
(c) => c.trackId === trackId && c.clipId === clipId
|
||||
);
|
||||
if (multi) {
|
||||
// Toggle selection
|
||||
return exists
|
||||
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
|
||||
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||
} else {
|
||||
return { selectedClips: [{ trackId, clipId }] };
|
||||
}
|
||||
});
|
||||
},
|
||||
deselectClip: (trackId, clipId) => {
|
||||
set((state) => ({
|
||||
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
|
||||
}));
|
||||
},
|
||||
clearSelectedClips: () => {
|
||||
set({ selectedClips: [] });
|
||||
},
|
||||
|
||||
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
||||
|
||||
addTrack: (type) => {
|
||||
get().pushHistory();
|
||||
const newTrack: TimelineTrack = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
|
||||
type,
|
||||
clips: [],
|
||||
muted: false,
|
||||
};
|
||||
set((state) => ({
|
||||
tracks: [...state.tracks, newTrack],
|
||||
}));
|
||||
return newTrack.id;
|
||||
},
|
||||
|
||||
removeTrack: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.filter((track) => track.id !== trackId),
|
||||
}));
|
||||
},
|
||||
|
||||
addClipToTrack: (trackId, clipData) => {
|
||||
get().pushHistory();
|
||||
const newClip: TimelineClip = {
|
||||
...clipData,
|
||||
id: crypto.randomUUID(),
|
||||
startTime: clipData.startTime || 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? { ...track, clips: [...track.clips, newClip] }
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeClipFromTrack: (trackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks
|
||||
.map((track) =>
|
||||
track.id === trackId
|
||||
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
|
||||
: track
|
||||
)
|
||||
// Remove track if it becomes empty
|
||||
.filter((track) => track.clips.length > 0),
|
||||
}));
|
||||
},
|
||||
|
||||
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => {
|
||||
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
||||
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
|
||||
|
||||
if (!clipToMove) return state;
|
||||
|
||||
return {
|
||||
tracks: state.tracks
|
||||
.map((track) => {
|
||||
if (track.id === fromTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||
};
|
||||
} else if (track.id === toTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: [...track.clips, clipToMove],
|
||||
};
|
||||
}
|
||||
return track;
|
||||
})
|
||||
// Remove track if it becomes empty
|
||||
.filter((track) => track.clips.length > 0),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
updateClipStartTime: (trackId, clipId, startTime) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, startTime } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleTrackMute: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId ? { ...track, muted: !track.muted } : track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
getTotalDuration: () => {
|
||||
const { tracks } = get();
|
||||
if (tracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = tracks.map((track) =>
|
||||
track.clips.reduce((maxEnd, clip) => {
|
||||
const clipEnd =
|
||||
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
|
||||
return Math.max(maxEnd, clipEnd);
|
||||
}, 0)
|
||||
);
|
||||
|
||||
return Math.max(...trackEndTimes, 0);
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { redoStack } = get();
|
||||
if (redoStack.length === 0) return;
|
||||
const next = redoStack[redoStack.length - 1];
|
||||
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
||||
},
|
||||
}));
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface TimelineClip {
|
||||
id: string;
|
||||
mediaId: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
export interface TimelineTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "video" | "audio" | "effects";
|
||||
clips: TimelineClip[];
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineStore {
|
||||
tracks: TimelineTrack[];
|
||||
history: TimelineTrack[][];
|
||||
redoStack: TimelineTrack[][];
|
||||
|
||||
// Multi-selection
|
||||
selectedClips: { trackId: string; clipId: string }[];
|
||||
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
|
||||
deselectClip: (trackId: string, clipId: string) => void;
|
||||
clearSelectedClips: () => void;
|
||||
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
||||
|
||||
// Drag state
|
||||
dragState: {
|
||||
isDragging: boolean;
|
||||
clipId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startClipTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
};
|
||||
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
|
||||
startDrag: (
|
||||
clipId: string,
|
||||
trackId: string,
|
||||
startMouseX: number,
|
||||
startClipTime: number,
|
||||
clickOffsetTime: number
|
||||
) => void;
|
||||
updateDragTime: (currentTime: number) => void;
|
||||
endDrag: () => void;
|
||||
|
||||
// Actions
|
||||
addTrack: (type: "video" | "audio" | "effects") => string;
|
||||
removeTrack: (trackId: string) => void;
|
||||
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
|
||||
removeClipFromTrack: (trackId: string, clipId: string) => void;
|
||||
moveClipToTrack: (
|
||||
fromTrackId: string,
|
||||
toTrackId: string,
|
||||
clipId: string
|
||||
) => void;
|
||||
updateClipTrim: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
trimStart: number,
|
||||
trimEnd: number
|
||||
) => void;
|
||||
updateClipStartTime: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
startTime: number
|
||||
) => void;
|
||||
toggleTrackMute: (trackId: string) => void;
|
||||
|
||||
// Computed values
|
||||
getTotalDuration: () => number;
|
||||
|
||||
// New actions
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
pushHistory: () => void;
|
||||
}
|
||||
|
||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
||||
tracks: [],
|
||||
history: [],
|
||||
redoStack: [],
|
||||
selectedClips: [],
|
||||
|
||||
pushHistory: () => {
|
||||
const { tracks, history, redoStack } = get();
|
||||
// Deep copy tracks
|
||||
set({
|
||||
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
||||
redoStack: [], // Clear redo stack when new action is performed
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { history, redoStack, tracks } = get();
|
||||
if (history.length === 0) return;
|
||||
const prev = history[history.length - 1];
|
||||
set({
|
||||
tracks: prev,
|
||||
history: history.slice(0, -1),
|
||||
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack
|
||||
});
|
||||
},
|
||||
|
||||
selectClip: (trackId, clipId, multi = false) => {
|
||||
set((state) => {
|
||||
const exists = state.selectedClips.some(
|
||||
(c) => c.trackId === trackId && c.clipId === clipId
|
||||
);
|
||||
if (multi) {
|
||||
// Toggle selection
|
||||
return exists
|
||||
? {
|
||||
selectedClips: state.selectedClips.filter(
|
||||
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||
),
|
||||
}
|
||||
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||
} else {
|
||||
return { selectedClips: [{ trackId, clipId }] };
|
||||
}
|
||||
});
|
||||
},
|
||||
deselectClip: (trackId, clipId) => {
|
||||
set((state) => ({
|
||||
selectedClips: state.selectedClips.filter(
|
||||
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||
),
|
||||
}));
|
||||
},
|
||||
clearSelectedClips: () => {
|
||||
set({ selectedClips: [] });
|
||||
},
|
||||
|
||||
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
||||
|
||||
addTrack: (type) => {
|
||||
get().pushHistory();
|
||||
const newTrack: TimelineTrack = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
|
||||
type,
|
||||
clips: [],
|
||||
muted: false,
|
||||
};
|
||||
set((state) => ({
|
||||
tracks: [...state.tracks, newTrack],
|
||||
}));
|
||||
return newTrack.id;
|
||||
},
|
||||
|
||||
removeTrack: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.filter((track) => track.id !== trackId),
|
||||
}));
|
||||
},
|
||||
|
||||
addClipToTrack: (trackId, clipData) => {
|
||||
get().pushHistory();
|
||||
const newClip: TimelineClip = {
|
||||
...clipData,
|
||||
id: crypto.randomUUID(),
|
||||
startTime: clipData.startTime || 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? { ...track, clips: [...track.clips, newClip] }
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeClipFromTrack: (trackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks
|
||||
.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||
}
|
||||
: track
|
||||
)
|
||||
// Remove track if it becomes empty
|
||||
.filter((track) => track.clips.length > 0),
|
||||
}));
|
||||
},
|
||||
|
||||
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => {
|
||||
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
||||
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
|
||||
|
||||
if (!clipToMove) return state;
|
||||
|
||||
return {
|
||||
tracks: state.tracks
|
||||
.map((track) => {
|
||||
if (track.id === fromTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||
};
|
||||
} else if (track.id === toTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: [...track.clips, clipToMove],
|
||||
};
|
||||
}
|
||||
return track;
|
||||
})
|
||||
// Remove track if it becomes empty
|
||||
.filter((track) => track.clips.length > 0),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
updateClipStartTime: (trackId, clipId, startTime) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, startTime } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleTrackMute: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId ? { ...track, muted: !track.muted } : track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
getTotalDuration: () => {
|
||||
const { tracks } = get();
|
||||
if (tracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = tracks.map((track) =>
|
||||
track.clips.reduce((maxEnd, clip) => {
|
||||
const clipEnd =
|
||||
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
|
||||
return Math.max(maxEnd, clipEnd);
|
||||
}, 0)
|
||||
);
|
||||
|
||||
return Math.max(...trackEndTimes, 0);
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { redoStack } = get();
|
||||
if (redoStack.length === 0) return;
|
||||
const next = redoStack[redoStack.length - 1];
|
||||
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
||||
},
|
||||
|
||||
dragState: {
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
},
|
||||
|
||||
setDragState: (dragState) =>
|
||||
set((state) => ({
|
||||
dragState: { ...state.dragState, ...dragState },
|
||||
})),
|
||||
|
||||
startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
|
||||
set({
|
||||
dragState: {
|
||||
isDragging: true,
|
||||
clipId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startClipTime,
|
||||
clickOffsetTime,
|
||||
currentTime: startClipTime,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
updateDragTime: (currentTime) => {
|
||||
set((state) => ({
|
||||
dragState: {
|
||||
...state.dragState,
|
||||
currentTime,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
endDrag: () => {
|
||||
set({
|
||||
dragState: {
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
1
apps/web/src/types/editor.ts
Normal file
@ -0,0 +1 @@
|
||||
export type BackgroundType = "blur" | "mirror" | "color";
|
27
apps/web/src/types/timeline.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
|
||||
|
||||
export interface TimelineClipProps {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
onContextMenu: (e: React.MouseEvent, clipId: string) => void;
|
||||
onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
|
||||
onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
clipId: string;
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
initialTrimStart: number;
|
||||
initialTrimEnd: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
type: "track" | "clip";
|
||||
trackId: string;
|
||||
clipId?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
@ -1,13 +1,10 @@
|
||||
[build]
|
||||
base = "."
|
||||
command = "bun install && bunx turbo build --filter=opencut"
|
||||
publish = "apps/web/.next"
|
||||
|
||||
# Next.js plugin
|
||||
[[plugins]]
|
||||
package = "@netlify/plugin-nextjs"
|
||||
|
||||
# Redirects for domain migration
|
||||
[[redirects]]
|
||||
from = "https://appcut.app/*"
|
||||
to = "https://opencut.app/:splat"
|
||||
status = 301
|
||||
force = true
|
||||
force = true
|