Merge branch 'OpenCut-app:main' into main

This commit is contained in:
vadi25 2025-07-17 12:20:47 +02:00 committed by GitHub
commit 9af8905c0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 171 additions and 69 deletions

View File

@ -15,6 +15,27 @@ Thank you for your interest in contributing to OpenCut! This document provides g
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
> 2. Use an alternative package manager such as **bun** or **pnpm**.
## What to Focus On
**🎯 Good Areas to Contribute:**
- Timeline functionality and UI improvements
- Project management features
- Performance optimizations
- Bug fixes in existing functionality
- UI/UX improvements
- Documentation and testing
**⚠️ Areas to Avoid:**
- Preview panel enhancements (text fonts, stickers, effects)
- Export functionality improvements
- Preview rendering optimizations
**Why?** We're currently planning a major refactor of the preview system. The current preview renders DOM elements (HTML), but we're moving to a binary rendering approach similar to CapCut. This new system will ensure consistency between preview and export, and provide much better performance and quality.
The current HTML-based preview is essentially a prototype - the binary approach will be the "real deal." To avoid wasted effort, please focus on other areas of the application until this refactor is complete.
If you're unsure whether your idea falls into the preview category, feel free to ask us [directly in discord](https://discord.gg/zmR9N35cjK) or create a GitHub issue!
## Development Setup
### Prerequisites

View File

@ -23,6 +23,10 @@ jobs:
env:
DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut"
BETTER_AUTH_SECRET: "supersecret"
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
steps:
- name: Checkout repository

View File

@ -129,11 +129,13 @@ The application will be available at [http://localhost:3000](http://localhost:30
## Contributing
**Note**: We're currently moving at an extremely fast pace with rapid development and breaking changes. While we appreciate the interest, it's recommended to wait until the project stabilizes before contributing to avoid conflicts and wasted effort.
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
**Quick start for contributors:**

View File

@ -140,12 +140,6 @@ export default function Editor() {
</ResizablePanelGroup>
</div>
</div>
<Onboarding
isOpen={isOnboardingOpen}
onClose={() => {
setIsOnboardingOpen(false);
}}
/>
</EditorProvider>
);
}

View File

@ -1,8 +1,7 @@
"use client"
import { redirect } from "next/navigation";
"use client";
import Link from "next/link";
import React, { useState } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
@ -29,15 +28,8 @@ import { useProjectStore } from "@/stores/project-store";
import { useRouter } from "next/navigation";
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { toast } from "sonner";
export default function ProjectsPage() {
if (process.env.NODE_ENV !== "development") {
toast.error("You are not allowed to access this page");
redirect("/");
}
const {
createNewProject,
savedProjects,

View File

@ -347,7 +347,7 @@ export function PreviewPanel() {
{hasAnyElements ? (
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm border"
className="relative overflow-hidden border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,

View File

@ -54,12 +54,59 @@ export function TimelineTrackContent({
const { currentTime } = usePlaybackStore();
// Initialize snapping hook
const { snapElementPosition } = useTimelineSnapping({
const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
});
// Helper function for drop snapping that tries both edges
const getDropSnappedTime = (
dropTime: number,
elementDuration: number,
excludeElementId?: string
) => {
if (!snappingEnabled) {
// Use frame snapping if project has FPS, otherwise use decimal snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
return snapTimeToFrame(dropTime, projectFps);
}
// Try snapping both start and end edges for drops
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true // snap to start edge
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
return bestSnapResult.snappedTime;
};
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
@ -103,15 +150,52 @@ export function TimelineTrackContent({
let finalTime = adjustedTime;
let snapPoint = null;
if (snappingEnabled) {
const snapResult = snapElementPosition(
// Find the element being dragged to get its duration
let elementDuration = 5; // fallback duration
if (dragState.elementId && dragState.trackId) {
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const element = sourceTrack?.elements.find(
(e) => e.id === dragState.elementId
);
if (element) {
elementDuration =
element.duration - element.trimStart - element.trimEnd;
}
}
// Try snapping both start and end edges
const startSnapResult = snapElementEdge(
adjustedTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
dragState.elementId || undefined
dragState.elementId || undefined,
true // snap to start edge
);
finalTime = snapResult.snappedTime;
snapPoint = snapResult.snapPoint;
const endSnapResult = snapElementEdge(
adjustedTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
dragState.elementId || undefined,
false // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
finalTime = bestSnapResult.snappedTime;
snapPoint = bestSnapResult.snapPoint;
// Notify parent component about snap point change
onSnapPointChange?.(snapPoint);
@ -390,9 +474,10 @@ export function TimelineTrackContent({
if (dragData.type === "text") {
// Text elements have default duration of 5 seconds
const newElementDuration = 5;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const snappedTime = getDropSnappedTime(
dropTime,
newElementDuration
);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@ -411,9 +496,10 @@ export function TimelineTrackContent({
);
if (mediaItem) {
const newElementDuration = mediaItem.duration || 5;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const snappedTime = getDropSnappedTime(
dropTime,
newElementDuration
);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@ -453,9 +539,11 @@ export function TimelineTrackContent({
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const snappedTime = getDropSnappedTime(
dropTime,
movingElementDuration,
elementId
);
const movingElementEnd = snappedTime + movingElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@ -482,17 +570,15 @@ export function TimelineTrackContent({
if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
setWouldOverlap(true);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
setDropPosition(snapTimeToFrame(dropTime, projectFps));
// Use default duration for position indicator
setDropPosition(getDropSnappedTime(dropTime, 5));
return;
}
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
setWouldOverlap(false);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
setDropPosition(snapTimeToFrame(dropTime, projectFps));
// Use default duration for position indicator
setDropPosition(getDropSnappedTime(dropTime, 5));
};
const handleTrackDragEnter = (e: React.DragEvent) => {
@ -614,18 +700,20 @@ export function TimelineTrackContent({
return;
}
// Adjust position based on where user clicked on the element
const adjustedStartTime = snappedTime - clickOffsetTime;
const finalStartTime = Math.max(
0,
snapTimeToFrame(adjustedStartTime, projectFps)
);
// Check for overlaps with existing elements (excluding the moving element itself)
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
// Adjust position based on where user clicked on the element
const adjustedStartTime = newStartTime - clickOffsetTime;
const snappedStartTime = getDropSnappedTime(
adjustedStartTime,
movingElementDuration,
elementId
);
const finalStartTime = Math.max(0, snappedStartTime);
const movingElementEnd = finalStartTime + movingElementDuration;
const hasOverlap = track.elements.some((existingElement) => {
@ -711,7 +799,11 @@ export function TimelineTrackContent({
// Check for overlaps with existing elements in target track
const newElementDuration = 5; // Default text duration
const newElementEnd = snappedTime + newElementDuration;
const textSnappedTime = getDropSnappedTime(
newStartTime,
newElementDuration
);
const newElementEnd = textSnappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
@ -722,7 +814,9 @@ export function TimelineTrackContent({
existingElement.trimEnd);
// Check if elements overlap
return snappedTime < existingEnd && newElementEnd > existingStart;
return (
textSnappedTime < existingEnd && newElementEnd > existingStart
);
});
if (hasOverlap) {
@ -737,7 +831,7 @@ export function TimelineTrackContent({
name: dragData.name || "Text",
content: dragData.content || "Default Text",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: snappedTime,
startTime: textSnappedTime,
trimStart: 0,
trimEnd: 0,
fontSize: 48,
@ -857,7 +951,11 @@ export function TimelineTrackContent({
// Check for overlaps with existing elements in target track
const newElementDuration = mediaItem.duration || 5;
const newElementEnd = snappedTime + newElementDuration;
const mediaSnappedTime = getDropSnappedTime(
newStartTime,
newElementDuration
);
const newElementEnd = mediaSnappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
@ -868,7 +966,9 @@ export function TimelineTrackContent({
existingElement.trimEnd);
// Check if elements overlap
return snappedTime < existingEnd && newElementEnd > existingStart;
return (
mediaSnappedTime < existingEnd && newElementEnd > existingStart
);
});
if (hasOverlap) {
@ -883,7 +983,7 @@ export function TimelineTrackContent({
mediaId: mediaItem.id,
name: mediaItem.name,
duration: mediaItem.duration || 5,
startTime: snappedTime,
startTime: mediaSnappedTime,
trimStart: 0,
trimEnd: 0,
});

View File

@ -34,15 +34,15 @@ export function Footer() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">
<div className="flex items-center gap-2 mb-4">
<div className="flex justify-start items-center gap-2 mb-4">
<Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
<span className="font-bold text-lg">OpenCut</span>
</div>
<p className="text-sm text-muted-foreground mb-5">
<p className="text-sm md:text-left text-muted-foreground mb-5">
The open source video editor that gets the job done. Simple,
powerful, and works on any platform.
</p>
<div className="flex gap-3">
<div className="flex justify-start gap-3">
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-muted-foreground hover:text-foreground transition-colors"
@ -70,7 +70,7 @@ export function Footer() {
</div>
</div>
<div className="flex gap-12 justify-end items-start py-2">
<div className="flex gap-12 justify-start items-start py-2">
<div>
<h3 className="font-semibold text-foreground mb-4">Resources</h3>
<ul className="space-y-2 text-sm">
@ -129,7 +129,7 @@ export function Footer() {
</div>
{/* Bottom Section */}
<div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="pt-2 flex flex-col md:flex-row justify-between items-start gap-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>© 2025 OpenCut, All Rights Reserved</span>
</div>

View File

@ -5,7 +5,6 @@ import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
import { env } from "@/env";
interface ProjectStore {
activeProject: TProject | null;
@ -37,16 +36,6 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
isInitialized: false,
createNewProject: async (name: string) => {
try {
if (process.env.NODE_ENV !== "development") {
toast.error("Project creation is disabled outside development environment");
throw new Error("Not allowed in production");
}
} catch (error) {
toast.error("Failed to create new project");
throw error;
}
const newProject: TProject = {
id: generateUUID(),
name,