This commit is contained in:
Maze Winther
2025-06-25 16:18:00 +02:00
21 changed files with 645 additions and 218 deletions

View File

@ -1,30 +1,43 @@
FROM oven/bun:latest AS base
FROM oven/bun:alpine AS base
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Build the application
# Install dependencies and build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY package.json package.json
COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/auth/package.json packages/auth/package.json
RUN bun install
COPY apps/web/ apps/web/
COPY packages/db/ packages/db/
COPY packages/auth/ packages/auth/
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/apps/web
RUN bun run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
RUN chown nextjs:nodejs apps
USER nextjs
@ -33,4 +46,4 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "server.js"]
CMD ["bun", "apps/web/server.js"]

View File

@ -13,7 +13,7 @@ if (!process.env.DATABASE_URL) {
}
export default {
schema: "./src/lib/db/schema.ts",
schema: "../../packages/db/src/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,

View File

@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
},
reactStrictMode: true,
productionBrowserSourceMaps: true,
output: "standalone",
};
export default nextConfig;

View File

@ -21,7 +21,6 @@
"@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*",
"@types/pg": "^8.15.4",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1",
@ -57,6 +56,7 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/pg": "^8.15.4",
"@types/bun": "latest",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
return new Response("OK", { status: 200 });
}

View File

@ -16,6 +16,35 @@ 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 default function RootLayout({

View File

@ -6,6 +6,7 @@ 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();
@ -24,13 +25,15 @@ export function EditorHeader() {
};
const leftContent = (
<Link
href="/"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
<span className="text-sm">{activeProject?.name || "Loading..."}</span>
</Link>
<div className="flex items-center gap-2">
<Link
href="/"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<ProjectNameEditor />
</div>
);
const centerContent = (

View File

@ -0,0 +1,115 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
interface AudioWaveformProps {
audioUrl: string;
height?: number;
className?: string;
}
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = ''
}) => {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
try {
// Clean up any existing instance
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: 'rgba(255, 255, 255, 0.6)',
progressColor: 'rgba(255, 255, 255, 0.9)',
cursorColor: 'transparent',
barWidth: 2,
barGap: 1,
height: height,
normalize: true,
interact: false,
});
// Event listeners
wavesurfer.current.on('ready', () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on('error', (err) => {
console.error('WaveSurfer error:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
});
await wavesurfer.current.load(audioUrl);
} catch (err) {
console.error('Failed to initialize WaveSurfer:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
}
};
initWaveSurfer();
return () => {
mounted = false;
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
};
}, [audioUrl, height]);
if (error) {
return (
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
<span className="text-xs text-foreground/60">Audio unavailable</span>
</div>
);
}
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-foreground/60">Loading...</span>
</div>
)}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;

View File

@ -84,17 +84,20 @@ export function MediaPanel() {
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
@ -209,23 +212,23 @@ export function MediaPanel() {
{/* Button to add/upload media */}
<div className="flex gap-2">
{/* Search and filter controls */}
<select
value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background"
>
<option value="all">All</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="image">Image</option>
</select>
<input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background"
>
<option value="all">All</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="image">Image</option>
</select>
<input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Add media button */}
<Button
@ -233,21 +236,26 @@ export function MediaPanel() {
size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none min-w-[80px] whitespace-nowrap"
className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
>
{isProcessing ? (
<>
<Upload className="h-4 w-4 mr-2 animate-spin" />
Processing...
<Upload className="h-4 w-4 animate-spin" />
<span className="hidden md:inline ml-2">Processing...</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
Add
<span
className="hidden sm:inline ml-2"
aria-label="Add file"
>
Add
</span>
</>
)}
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
@ -276,7 +284,15 @@ export function MediaPanel() {
<AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)}
</AspectRatio>
<span className="text-xs truncate px-1">{item.name}</span>
<span
className="text-xs truncate px-1 max-w-full"
aria-label={item.name}
title={item.name}
>
{item.name.length > 8
? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
: item.name}
</span>
</Button>
{/* Show remove button on hover */}

View File

@ -5,16 +5,17 @@ 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 } from "lucide-react";
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';
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime } = usePlaybackStore();
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);
@ -30,12 +31,14 @@ export function PreviewPanel() {
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
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);
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 });
@ -134,13 +137,19 @@ export function PreviewPanel() {
<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 });
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}`}>
{canvasPresets.map((preset) => (
<option
key={preset.name}
value={`${preset.width}x${preset.height}`}
>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
@ -154,12 +163,30 @@ export function PreviewPanel() {
onClick={() => setShowDebug(!showDebug)}
className="text-xs"
>
Debug {showDebug ? 'ON' : 'OFF'}
Debug {showDebug ? "ON" : "OFF"}
</Button>
)}
<Button variant="outline" size="sm" onClick={toggle} className="ml-auto">
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
<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>
@ -177,7 +204,9 @@ export function PreviewPanel() {
>
{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"}
{tracks.length === 0
? "Drop media to start editing"
: "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
@ -188,7 +217,9 @@ export function PreviewPanel() {
{/* 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="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
@ -199,7 +230,9 @@ export function PreviewPanel() {
{index + 1}
</span>
<span>{clipData.clip.name}</span>
<span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
<span className="text-muted-foreground">
({clipData.mediaItem?.type || "test"})
</span>
</div>
))}
{activeClips.length === 0 && (

View File

@ -0,0 +1,110 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Input } from "../ui/input";
import { useProjectStore } from "@/stores/project-store";
import { Edit2, Check, X } from "lucide-react";
import { Button } from "../ui/button";
interface ProjectNameEditorProps {
className?: string;
}
export function ProjectNameEditor({ className }: ProjectNameEditorProps) {
const { activeProject, updateProjectName } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (activeProject) {
setEditValue(activeProject.name);
}
}, [activeProject]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleStartEdit = () => {
if (activeProject) {
setEditValue(activeProject.name);
setIsEditing(true);
}
};
const handleSave = () => {
if (editValue.trim()) {
updateProjectName(editValue.trim());
setIsEditing(false);
}
};
const handleCancel = () => {
if (activeProject) {
setEditValue(activeProject.name);
}
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
};
if (!activeProject) {
return <span className="text-sm text-muted-foreground">Loading...</span>;
}
if (isEditing) {
return (
<div className="flex items-center gap-1">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-7 text-sm px-3 py-1 min-w-[200px]"
size={1}
/>
<Button
size="sm"
variant="text"
onClick={handleSave}
className="h-7 w-7 p-0"
disabled={!editValue.trim()}
>
<Check className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="text"
onClick={handleCancel}
className="h-7 w-7 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
return (
<div className="flex items-center gap-1 group">
<span className="text-sm font-medium">{activeProject.name}</span>
<Button
size="sm"
variant="text"
onClick={handleStartEdit}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit2 className="h-3 w-3" />
</Button>
</div>
);
}

View File

@ -35,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import AudioWaveform from "./audio-waveform";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@ -221,7 +222,7 @@ export function Timeline() {
const clipLeft = clip.startTime * 50 * zoomLevel;
const clipTop = trackIdx * 60;
const clipBottom = clipTop + 60;
const clipRight = clipLeft + clipWidth;
const clipRight = clipLeft + 60; // Set a fixed width for time display
if (
bx1 < clipRight &&
bx2 > clipLeft &&
@ -566,15 +567,6 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel}
>
{/* Show overlay when dragging media over the timeline */}
{isDragOver && (
<div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
<div>
Drop media here to add to timeline
</div>
</div>
)}
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
@ -602,38 +594,40 @@ export function Timeline() {
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div className="text-xs text-muted-foreground font-mono px-2">
{Math.floor(currentTime * 10) / 10}s /{" "}
{Math.floor(duration * 10) / 10}s
<div className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: '18ch', textAlign: 'center' }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
<div className="w-px h-6 bg-border mx-1" />
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("video");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("video");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
@ -951,6 +945,17 @@ export function Timeline() {
)}
</>
)}
{isDragOver && (
<div
className="absolute left-0 right-0 border-2 border-dashed border-accent flex items-center justify-center text-muted-foreground"
style={{
top: `${tracks.length * 60}px`,
height: "60px",
}}
>
<div>Drop media here to add a new track</div>
</div>
)}
</div>
</div>
</div>
@ -1624,7 +1629,21 @@ function TimelineTrackContent({
);
}
// Fallback for audio or videos without thumbnails
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>
);

View File

@ -4,108 +4,128 @@ import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
src: string;
poster?: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
}
export function VideoPlayer({
src,
poster,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration
src,
poster,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed } = usePlaybackStore();
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events
useEffect(() => {
const video = videoRef.current;
if (!video || !isInClipRange) return;
// Sync playback events
useEffect(() => {
const video = videoRef.current;
if (!video || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const videoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
));
video.currentTime = videoTime;
};
const handleSeekEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const videoTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
video.currentTime = videoTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
));
const handleUpdateEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(video.currentTime - targetTime) > 0.5) {
video.currentTime = targetTime;
}
};
if (Math.abs(video.currentTime - targetTime) > 0.5) {
video.currentTime = targetTime;
}
};
const handleSpeed = (e: CustomEvent) => {
video.playbackRate = e.detail.speed;
};
const handleSpeed = (e: CustomEvent) => {
video.playbackRate = e.detail.speed;
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
window.removeEventListener("playback-speed", handleSpeed as EventListener);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => { });
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.playbackRate = speed;
}, [volume, speed]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: 'none' }}
onContextMenu={(e) => e.preventDefault()}
/>
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
}
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => {});
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.muted = muted;
video.playbackRate = speed;
}, [volume, speed, muted]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: "none" }}
onContextMenu={(e) => e.preventDefault()}
/>
);
}

View File

@ -10,7 +10,7 @@ let playbackTimer: number | null = null;
const startTimer = (store: any) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates
const updateTime = () => {
const state = store();
@ -18,14 +18,22 @@ const startTimer = (store: any) => {
const now = performance.now();
const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now;
const newTime = state.currentTime + (delta * state.speed);
const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) {
// When video completes, pause and reset playhead to start
state.pause();
state.setCurrentTime(0);
// Notify video elements to sync with reset
window.dispatchEvent(
new CustomEvent("playback-seek", { detail: { time: 0 } })
);
} else {
state.setCurrentTime(newTime);
// Notify video elements to sync
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
window.dispatchEvent(
new CustomEvent("playback-update", { detail: { time: newTime } })
);
}
}
playbackTimer = requestAnimationFrame(updateTime);
@ -47,6 +55,8 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0,
duration: 0,
volume: 1,
muted: false,
previousVolume: 1,
speed: 1.0,
play: () => {
@ -72,22 +82,53 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
const { duration } = get();
const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime });
// Notify video elements to seek
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
const event = new CustomEvent("playback-seek", {
detail: { time: clampedTime },
});
window.dispatchEvent(event);
},
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
setVolume: (volume: number) =>
set((state) => ({
volume: Math.max(0, Math.min(1, volume)),
muted: volume === 0,
previousVolume: volume > 0 ? volume : state.previousVolume,
})),
setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed });
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
const event = new CustomEvent("playback-speed", {
detail: { speed: newSpeed },
});
window.dispatchEvent(event);
},
setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }),
}));
mute: () => {
const { volume, previousVolume } = get();
set({
muted: true,
previousVolume: volume > 0 ? volume : previousVolume,
volume: 0,
});
},
unmute: () => {
const { previousVolume } = get();
set({ muted: false, volume: previousVolume ?? 1 });
},
toggleMute: () => {
const { muted } = get();
if (muted) {
get().unmute();
} else {
get().mute();
}
},
}));

View File

@ -7,6 +7,7 @@ interface ProjectStore {
// Actions
createNewProject: (name: string) => void;
closeProject: () => void;
updateProjectName: (name: string) => void;
}
export const useProjectStore = create<ProjectStore>((set) => ({
@ -25,4 +26,16 @@ export const useProjectStore = create<ProjectStore>((set) => ({
closeProject: () => {
set({ activeProject: null });
},
updateProjectName: (name: string) => {
set((state) => ({
activeProject: state.activeProject
? {
...state.activeProject,
name,
updatedAt: new Date(),
}
: null,
}));
},
}));

View File

@ -4,6 +4,8 @@ export interface PlaybackState {
duration: number;
volume: number;
speed: number;
muted: boolean;
previousVolume?: number;
}
export interface PlaybackControls {
@ -13,4 +15,7 @@ export interface PlaybackControls {
setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void;
}
mute: () => void;
unmute: () => void;
toggleMute: () => void;
}

View File

@ -4,6 +4,7 @@
"": {
"dependencies": {
"next": "^15.3.4",
"wavesurfer.js": "^7.9.8",
},
"devDependencies": {
"turbo": "^2.5.4",
@ -902,6 +903,8 @@
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],

View File

@ -48,7 +48,7 @@ services:
start_period: 10s
web:
build:
context: ./apps/web
context: .
dockerfile: ./apps/web/Dockerfile
restart: unless-stopped
ports:

View File

@ -16,6 +16,7 @@
"format": "turbo run format"
},
"dependencies": {
"next": "^15.3.4"
"next": "^15.3.4",
"wavesurfer.js": "^7.9.8"
}
}