Compare commits

...
3 Commits
Author SHA1 Message Date
Maze Winther fdb4dff755 docs: mark repo as archived legacy 2026-05-16 23:47:38 +02:00
Maze Winther 6d96674dc2 fix: type error 2026-05-16 05:52:30 +02:00
Maze Winther 0d35499356 fix: canvas dragging for keyframed positions 2026-05-16 05:27:20 +02:00
2 changed files with 137 additions and 186 deletions
+3 -165
View File
@@ -1,167 +1,5 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
# OpenCut (Legacy)
## Sponsors
This is the original OpenCut codebase. It's archived and no longer maintained.
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
## Why?
- **Privacy**: Your videos stay on your device
- **Free features**: Most basic CapCut features are now paywalled
- **Simple**: People want editors that are easy to use - CapCut proved that
## Project Structure
- `apps/web/`: Next.js web application
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
- `docs/`: Architecture and subsystem documentation
## Getting Started
### Prerequisites
- [Bun](https://bun.sh/docs/installation)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
### Setup
1. Fork and clone the repository
2. Copy the environment file:
```bash
# Unix/Linux/Mac
cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell
Copy-Item apps/web/.env.example apps/web/.env.local
```
3. Start the database and Redis:
```bash
docker compose up -d db redis serverless-redis-http
```
4. Install dependencies and start the dev server:
```bash
bun install
bun dev:web
```
The application will be available at [http://localhost:3000](http://localhost:3000).
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
### Desktop setup
Desktop is opt-in. If you're only working on the web app, skip this entirely.
If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
### Local WASM development
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
**Prerequisites** — install these once before anything else:
```bash
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# build the WASM package
cargo install wasm-pack
# reruns the build on file changes, used by bun dev:wasm
cargo install cargo-watch
```
1. Build the package once from the repo root:
```bash
bun run build:wasm
```
2. Register the generated package for linking:
```bash
cd rust/wasm/pkg
bun link
```
3. Link `apps/web` to the local package:
```bash
cd apps/web
bun link opencut-wasm
```
4. Rebuild on changes while you work:
```bash
bun dev:wasm
```
To switch `apps/web` back to the published package, run:
```bash
cd apps/web
bun add opencut-wasm
```
### Self-Hosting with Docker
To run everything (including a production build of the app) in Docker:
```bash
docker compose up -d
```
The app will be available at [http://localhost:3100](http://localhost:3100).
## Contributing
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
**⚠️ 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:**
- Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
- Create a feature branch and submit a PR
## License
[MIT LICENSE](LICENSE)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
The rewrite is happening at [opencut-app/opencut](https://github.com/opencut-app/opencut).
@@ -19,6 +19,17 @@ import {
} from "@/preview/preview-snap";
import type { TCanvasSize } from "@/project/types";
import type { Transform } from "@/rendering";
import { resolveTransformAtTime } from "@/rendering/animation-values";
import {
getElementLocalTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type {
AnimationPropertyPath,
ElementAnimations,
} from "@/animation/types";
import { mediaTime, type MediaTime } from "@/wasm";
import { isVisualElement } from "@/timeline/element-utils";
import type {
ElementRef,
@@ -50,7 +61,12 @@ interface PendingGesture extends CapturedPointerState {
interface DragElementSnapshot {
readonly trackId: string;
readonly elementId: string;
readonly initialTransform: Transform;
readonly initialBaseTransform: Transform;
readonly initialResolvedTransform: Transform;
readonly animations: ElementAnimations | undefined;
readonly localTime: MediaTime;
readonly hasAnimatedPositionX: boolean;
readonly hasAnimatedPositionY: boolean;
}
interface DraggingGesture extends CapturedPointerState {
@@ -204,8 +220,10 @@ function movedPastDragThreshold({
function toDragElementSnapshots({
elementsWithTracks,
currentTime,
}: {
elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
currentTime: number;
}): DragElementSnapshot[] {
const isVisualTrackedElement = (value: {
track: TimelineTrack;
@@ -215,11 +233,107 @@ function toDragElementSnapshots({
return elementsWithTracks
.filter(isVisualTrackedElement)
.map(({ track, element }) => ({
trackId: track.id,
elementId: element.id,
initialTransform: element.transform,
}));
.map(({ track, element }) => {
const localTime = mediaTime({
ticks: getElementLocalTime({
timelineTime: currentTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
}),
});
return {
trackId: track.id,
elementId: element.id,
initialBaseTransform: element.transform,
initialResolvedTransform: resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
}),
animations: element.animations,
localTime,
hasAnimatedPositionX: hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.positionX",
}),
hasAnimatedPositionY: hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.positionY",
}),
};
});
}
function upsertPositionKeyframe({
animations,
propertyPath,
localTime,
value,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: MediaTime;
value: number;
}): ElementAnimations | undefined {
return upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value,
});
}
function buildDragPositionUpdates({
snapshot,
position,
}: {
snapshot: DragElementSnapshot;
position: Point;
}): Partial<VisualElement> {
const hasAnimatedPosition =
snapshot.hasAnimatedPositionX || snapshot.hasAnimatedPositionY;
const hasStaticPosition =
!snapshot.hasAnimatedPositionX || !snapshot.hasAnimatedPositionY;
let animations = snapshot.animations;
const updates: Partial<VisualElement> = {};
if (snapshot.hasAnimatedPositionX) {
animations = upsertPositionKeyframe({
animations,
propertyPath: "transform.positionX",
localTime: snapshot.localTime,
value: position.x,
});
}
if (snapshot.hasAnimatedPositionY) {
animations = upsertPositionKeyframe({
animations,
propertyPath: "transform.positionY",
localTime: snapshot.localTime,
value: position.y,
});
}
if (hasAnimatedPosition) {
updates.animations = animations;
}
if (hasStaticPosition) {
updates.transform = {
...snapshot.initialBaseTransform,
position: {
x: snapshot.hasAnimatedPositionX
? snapshot.initialBaseTransform.position.x
: position.x,
y: snapshot.hasAnimatedPositionY
? snapshot.initialBaseTransform.position.y
: position.y,
},
};
}
return updates;
}
export class PreviewInteractionController {
@@ -489,6 +603,7 @@ export class PreviewInteractionController {
elementsWithTracks: this.deps.timeline.getElementsWithTracks({
elements: dragSelection,
}),
currentTime: this.deps.scene.getCurrentTime(),
});
if (draggableElements.length === 0) {
@@ -536,8 +651,8 @@ export class PreviewInteractionController {
const deltaY = currentPos.y - drag.origin.y;
const proposedPosition = {
x: firstElement.initialTransform.position.x + deltaX,
y: firstElement.initialTransform.position.y + deltaY,
x: firstElement.initialResolvedTransform.position.x + deltaX,
y: firstElement.initialResolvedTransform.position.y + deltaY,
};
const shouldSnap = !this.deps.input.isShiftHeld();
@@ -560,23 +675,21 @@ export class PreviewInteractionController {
this.deps.preview.onSnapLinesChange?.(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
snappedPosition.x - firstElement.initialResolvedTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
snappedPosition.y - firstElement.initialResolvedTransform.position.y;
this.deps.timeline.previewElements(
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
updates: {
transform: {
...initialTransform,
position: {
x: initialTransform.position.x + deltaSnappedX,
y: initialTransform.position.y + deltaSnappedY,
},
drag.elements.map((snapshot) => ({
trackId: snapshot.trackId,
elementId: snapshot.elementId,
updates: buildDragPositionUpdates({
snapshot,
position: {
x: snapshot.initialResolvedTransform.position.x + deltaSnappedX,
y: snapshot.initialResolvedTransform.position.y + deltaSnappedY,
},
},
}),
})),
);
}