Compare commits

..
64 Commits
Author SHA1 Message Date
Maze 0997466331 fix: subscribe to playback time so keyframe button updates on frame change
Made-with: Cursor
2026-04-05 13:37:29 +02:00
Maze 1bd9fac203 refactor: rewrite animation system to use bindings and scalar channels 2026-04-05 13:15:58 +02:00
Maze 69da5bee0c style: tabs 2026-04-04 11:59:04 +02:00
Maze cb99925d75 refactor: swap out manual tabs withthe shared tabs component in stickers 2026-04-04 11:58:55 +02:00
Maze 78c6c497f1 chore: remove transcription envs 2026-04-04 08:53:10 +02:00
Maze Winther 22725f71fa chore: update opencut-wasm to 0.1.3 2026-04-03 03:37:41 +02:00
Maze Winther 1a8a1e889a refactor: update command execute methods to return CommandResult | undefined 2026-04-03 03:13:49 +02:00
Maze Winther 3516bd69f4 fix: use published opencut-wasm package 2026-04-01 21:30:26 +02:00
Maze Winther 57fc0fd323 chore: sort root package.json scripts 2026-04-01 19:29:12 +02:00
Maze Winther bd9ec023ee refactor: split constants by domain and isolate timeline panel from core 2026-04-01 19:27:49 +02:00
Maze Winther f4b9f40ab1 fix: update tooltip text for graph editor button 2026-04-01 18:17:23 +02:00
Maze Winther 93cc5d50f3 fix: don't crash when GPU isn't available 2026-04-01 18:13:27 +02:00
Maze Winther 8a1d37cf3e fix: align background blur with effect blur intensity scale 2026-04-01 15:15:41 +02:00
Maze Winther e579ae1202 feat: migrate GPU renderer from WebGL to wgpu/WASM 2026-04-01 13:57:32 +02:00
Maze Winther b048b739bd Merge branch 'refactor-selection-commands' 2026-03-31 23:40:59 +02:00
Maze Winther dc82826951 refactor: move track pruning into a post-command reactor 2026-03-31 23:39:43 +02:00
Maze Winther 608dc1a62f Merge branch 'feature/separate-audio' 2026-03-31 23:24:33 +02:00
Maze Winther f1d5932c54 feat: separate audio from elements 2026-03-31 23:23:36 +02:00
Maze Winther cc150c7cfb Merge branch 'refactor-selection-commands' 2026-03-31 22:54:06 +02:00
Maze Winther f8fb238b22 feat: centralize selection management 2026-03-31 22:53:16 +02:00
Maze Winther 0488a6be45 fix: normalize vertical drag direction in preferIndex placement 2026-03-31 22:38:56 +02:00
Maze Winther 0f5dca4f12 fix: build wasm before bun install in CI 2026-03-31 19:02:17 +02:00
Maze Winther 154b78c7d1 fix: allow timeline paste and duplicate commands to reassign updated tracks 2026-03-31 18:47:45 +02:00
Maze Winther d23be079cc Merge branch 'feature/track-placement' 2026-03-31 15:53:23 +02:00
Maze Winther 70a6d58b84 refactor: centralize track placement into its own module 2026-03-31 15:47:30 +02:00
Maze Winther dcf54c1250 feat: migrate time utilities from typescript to rust WASM 2026-03-31 04:03:52 +02:00
Maze Winther 5cc6abc3a4 style: swap ripple icon 2026-03-29 22:31:26 +02:00
Maze Winther 77a14b1023 refactor: standardize icon component props 2026-03-29 22:30:11 +02:00
Maze Winther 1c3b22edec feat: add keyframe graphs button 2026-03-29 21:48:33 +02:00
Maze Winther 41562baeb5 style: remove unnecessary margin from keyframe 2026-03-29 21:48:08 +02:00
Maze Winther df207e0f83 refactor: name magic numbers and rename el in landing handlebars 2026-03-29 21:18:07 +02:00
Maze Winther dc5956900e fix: eliminate first-paint flicker in landing handlebars 2026-03-29 20:45:48 +02:00
Maze Winther b095862cc0 fix: improve DATABASE_URL validation in web environment schema 2026-03-29 18:58:35 +02:00
Maze Winther 0a6ede73ae chore: update deps 2026-03-29 18:57:05 +02:00
Maze Winther 7ce0018c9b chore: update .gitignore 2026-03-29 18:55:56 +02:00
Maze Winther 9528d0053b chore: untrack files 2026-03-29 18:55:10 +02:00
Maze Winther 689853312d fix: remove OpenNext Cloudflare initialization 2026-03-29 18:35:58 +02:00
Maze Winther 5a1d9ef3f4 chore: remove unused middleware file 2026-03-29 18:30:16 +02:00
Rayan SalhabGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Mazecoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
eb66e03f0e fix(security): replace new Function() with safe math expression parser (#753)
* fix(security): replace new Function() with safe math expression parser

Replace the dangerous new Function() constructor in evaluateMathExpression()
with a recursive descent parser that safely evaluates basic arithmetic
expressions.

The Function constructor is a security anti-pattern that could enable
arbitrary code execution if the validation regex is ever bypassed or
relaxed. The new parser:

- Only supports numbers, +, -, *, /, parentheses, and whitespace
- Has no eval-like functionality that could execute arbitrary code
- Maintains backward compatibility with existing expressions
- Handles operator precedence and parentheses correctly

Fixes #725

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Maze <167211895+mazeincoding@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-29 18:27:05 +02:00
Maze Winther a2418751e6 chore: remove accidentally committed Zone.Identifier stream 2026-03-29 18:25:43 +02:00
Maze Winther 5777a555f6 feat: add shared Rust crates for cross-platform time utilities
Introduce `bridge` proc-macro crate with `#[export]` attribute that conditionally applies `wasm_bindgen` for WASM builds, and `time` crate porting all timecode/frame utilities from TypeScript to Rust.
2026-03-29 15:58:46 +02:00
Maze Winther 220ec757d4 refactor: move packages sub-folders into web app 2026-03-29 15:58:46 +02:00
Maze Winther 585e28d49b feat: add published status to changelog entries 2026-03-29 15:58:46 +02:00
Maze Winther 1261e663e1 docs: rewrite agents file 2026-03-29 15:58:46 +02:00
Maze Winther c34e01c63e refactor: clean up number formatting and step handling in property tabs 2026-03-29 15:58:46 +02:00
Maze Winther 3e75852ffe chore: remove dead songs tab 2026-03-29 15:58:46 +02:00
Maze Winther e95992b406 chore: add missing open-next config 2026-03-29 15:58:46 +02:00
Maze Winther 6df9d70b9b chore: update dep 2026-03-29 15:58:46 +02:00
Maze Winther f8a0390827 chore: rename proxy to middleware 2026-03-29 15:58:46 +02:00
Maze Winther d862e56e34 feat: init desktop app 2026-03-29 15:58:42 +02:00
Maze Winther d08fbd73e1 fix: add missing file 2026-03-29 15:57:05 +02:00
Maze Winther 4d127a038b chore: normalize line endings 2026-03-29 15:56:48 +02:00
Maze Winther 82817cb50c chore: next-env 2026-03-29 15:49:30 +02:00
Maze Winther a54b3ef076 chore: update botid 2026-03-29 15:49:30 +02:00
Maze Winther 6dd8191f4b chore: init cloudflare 2026-03-29 15:49:30 +02:00
Maze Winther 722dcdd88f fix: resolve typescript/lint errors 2026-03-29 15:49:30 +02:00
Maze Winther 8db3bead13 feat: masks, properties refactor, shaders, storage migrations, and more 2026-03-29 15:48:22 +02:00
Maze Winther 39ea298a9c chore: gitignore 2026-03-29 15:46:35 +02:00
Maze Winther 2c0fd4f86b untrack .cursor/ 2026-03-29 15:46:35 +02:00
Maze Winther 5e2b829f8a changelog: add already-completed item 2026-03-29 15:46:35 +02:00
Maze Winther b09635064a fix: assets having unreasonable widths and heights 2026-03-29 15:41:10 +02:00
Maze Winther 6018efc5ac changelog: new version with already-made changes 2026-03-29 15:41:10 +02:00
AspenandGitHub 7e2a913e13 Update OpenCut logo in README (#741) 2026-03-28 19:07:36 +01:00
kcfancherandGitHub 63496d05a2 Fix transcription sample-rate handling (#740)
Fix for #703.

Adds an optional sampleRate parameter to the decodeAudioToFloat32() function. The caption transcriber passes in a value of 16000 to prepare audio in the format Whisper expects.
2026-03-28 19:06:40 +01:00
273 changed files with 20417 additions and 14220 deletions
+2 -3
View File
@@ -45,10 +45,9 @@ Thank you for your interest in contributing to OpenCut! This document provides g
5. Install dependencies: `bun install`
6. Start the development server: `bun run dev`
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
> **Note:** Web development uses the published `opencut-wasm` package by default, so a fresh clone does not need a local WASM build.
>
> 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**.
> If you are editing `rust/wasm`, run `bun run build:wasm`, then `cd rust/wasm/pkg && bun link`, then `cd ../../../apps/web && bun link opencut-wasm`.
### Desktop setup
+20 -5
View File
@@ -31,16 +31,31 @@ jobs:
MARBLE_WORKSPACE_KEY: "placeholder"
FREESOUND_CLIENT_ID: "placeholder"
FREESOUND_API_KEY: "placeholder"
CLOUDFLARE_ACCOUNT_ID: "placeholder"
R2_ACCESS_KEY_ID: "placeholder"
R2_SECRET_ACCESS_KEY: "placeholder"
R2_BUCKET_NAME: "placeholder"
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust wasm target
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-pack
uses: jetli/wasm-pack-action@v0.4.0
with:
version: latest
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
- name: Build WASM
run: wasm-pack build rust/wasm --target bundler --out-dir pkg
- name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with:
+2
View File
@@ -22,3 +22,5 @@ bun.lockb
.open-next
target/
.cargo-tools/
Generated
+644 -34
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -2,4 +2,7 @@
resolver = "2"
members = [
"apps/desktop",
"rust/crates/time",
"rust/crates/bridge",
"rust/wasm", "rust/crates/gpu",
]
+39 -2
View File
@@ -1,10 +1,10 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</span></h1>
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
@@ -93,6 +93,43 @@ 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.
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:
-7
View File
@@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
FREESOUND_CLIENT_ID=your_client_id_here
FREESOUND_API_KEY=your_api_key_here
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
R2_ACCESS_KEY_ID=your_access_key_here
R2_SECRET_ACCESS_KEY=your_secret_key_here
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
MODAL_TRANSCRIPTION_URL=your_modal_url_here
+4
View File
@@ -8,3 +8,7 @@
.next/
.font-cache/
next-env.d.ts
*.tsbuildinfo
-10
View File
@@ -14,14 +14,9 @@ COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
COPY packages/env/package.json packages/env/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN bun install
COPY apps/web/ apps/web/
COPY packages/env/ packages/env/
COPY packages/ui/ packages/ui/
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
@@ -34,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder"
ENV R2_ACCESS_KEY_ID="build-placeholder"
ENV R2_SECRET_ACCESS_KEY="build-placeholder"
ENV R2_BUCKET_NAME="build-placeholder"
ENV MODAL_TRANSCRIPTION_URL="http://localhost:0"
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
+4 -2
View File
@@ -9,6 +9,7 @@ const changelog = defineCollection({
content: z.string(),
version: z.string(),
date: z.string(),
published: z.boolean().default(true),
title: z.string(),
description: z.string().optional(),
changes: z.array(
@@ -20,10 +21,11 @@ const changelog = defineCollection({
}),
transform: async (doc, { collection }) => {
const allDocs = await collection.documents();
const sorted = [...allDocs].sort((a, b) =>
const publishedDocs = allDocs.filter((entry) => entry.published !== false);
const sorted = [...publishedDocs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
const isLatest = sorted[0]?.version === doc.version;
const isLatest = doc.published !== false && sorted[0]?.version === doc.version;
return { ...doc, isLatest };
},
});
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.1.0"
date: "2026-02-23"
published: true
title: "Editor foundation"
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
changes:
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.2.0"
date: "2026-03-01"
published: true
title: "Motion & effects"
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
changes:
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.3.0"
date: "2026-03-04"
published: false
title: "Brand & polish"
description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements."
changes:
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-11
View File
@@ -1,19 +1,8 @@
import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
initOpenNextCloudflareForDev();
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.glsl": {
loaders: [require.resolve("raw-loader")],
as: "*.js",
},
},
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
+2 -4
View File
@@ -22,8 +22,6 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -54,6 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "^0.1.3",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -77,7 +76,7 @@
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zod": "4.3.6",
"zustand": "^5.0.2"
},
"devDependencies": {
@@ -95,7 +94,6 @@
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"raw-loader": "^4.0.2",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
"typescript": "^5.8.3",
+1 -1
View File
@@ -1,4 +1,4 @@
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
+17 -20
View File
@@ -2,9 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import { allChangelogs } from "content-collections";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { getSortedReleases } from "../utils";
import { getReleaseByVersion, getSortedReleases } from "../utils";
import {
ReleaseArticle,
ReleaseMeta,
@@ -17,12 +16,12 @@ import { CopyMarkdownButton } from "../components/copy-markdown-button";
type Props = { params: Promise<{ version: string }> };
export async function generateStaticParams() {
return allChangelogs.map((r) => ({ version: r.version }));
return getSortedReleases().map((release) => ({ version: release.version }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { version } = await params;
const release = allChangelogs.find((r) => r.version === version);
const release = getReleaseByVersion({ version });
if (!release) return {};
return {
title: `${release.title} (${release.version}) - OpenCut Changelog`,
@@ -33,10 +32,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function ReleaseDetailPage({ params }: Props) {
const { version } = await params;
const releases = getSortedReleases();
const index = releases.findIndex((r) => r.version === version);
const index = releases.findIndex((entry) => entry.version === version);
if (index === -1) notFound();
const release = releases[index];
const newer = index > 0 ? releases[index - 1] : null;
const older = index < releases.length - 1 ? releases[index + 1] : null;
@@ -52,20 +49,20 @@ export default async function ReleaseDetailPage({ params }: Props) {
All releases
</Link>
<ReleaseArticle variant="detail">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<ReleaseMeta release={release} />
<CopyMarkdownButton
description={release.description}
changes={release.changes}
/>
<ReleaseArticle variant="detail">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<ReleaseMeta release={release} />
<CopyMarkdownButton
description={release.description}
changes={release.changes}
/>
</div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div>
<ReleaseChanges release={release} />
</ReleaseArticle>
@@ -23,7 +23,7 @@ function buildMarkdown({
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) {
lines.push(`## ${getSectionTitle(type)}`);
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
@@ -87,7 +87,7 @@ export function ReleaseChanges({ release }: { release: Release }) {
{orderedTypes.map((type) => (
<div key={type} className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground">
{getSectionTitle(type)}:
{getSectionTitle({ type })}:
</h3>
<ul className="list-disc pl-5 space-y-1.5">
{grouped[type].map((change) => (
+14 -4
View File
@@ -12,7 +12,7 @@ const knownSectionTitles: Record<string, string> = {
breaking: "Breaking Changes",
};
export function getSectionTitle(type: string): string {
export function getSectionTitle({ type }: { type: string }): string {
return (
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
);
@@ -36,8 +36,18 @@ export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
return { grouped, orderedTypes };
}
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() {
return [...allChangelogs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
return allChangelogs
.filter(isPublishedRelease)
.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
}
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
+3 -3
View File
@@ -21,7 +21,7 @@ import type {
TProjectSortKey,
TProjectSortOption,
} from "@/lib/project/types";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -46,7 +46,7 @@ import {
ArrowDown02Icon,
InformationCircleIcon,
} from "@hugeicons/core-free-icons";
import { OcVideoIcon } from "@opencut/ui/icons";
import { OcVideoIcon } from "@/components/icons";
import { Label } from "@/components/ui/label";
import {
ContextMenu,
@@ -77,7 +77,7 @@ const formatProjectDuration = ({
}
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format });
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
};
const VIEW_MODE_OPTIONS = [
@@ -1,14 +1,13 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
import type { TTimeCode } from "@/lib/time";
import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
duration: number;
format?: TTimeCode;
format?: TimeCodeFormat;
fps: number;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
@@ -29,7 +28,7 @@ export function EditableTimecode({
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const enterPressedRef = useRef(false);
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
const startEditing = () => {
if (disabled) return;
@@ -49,7 +48,7 @@ export function EditableTimecode({
const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
if (parsedTime === null) {
if (parsedTime == null) {
setHasError(true);
return;
}
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { Button } from "@/components/ui/button";
function InfoRow({
@@ -37,11 +37,8 @@ export function ProjectInfoDialog({
}) {
const durationFormatted =
project.duration > 0
? formatTimeCode({
timeInSeconds: project.duration,
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
})
: "0:00";
? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
: "0:00";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
@@ -25,7 +25,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { invokeAction } from "@/lib/actions";
@@ -257,7 +257,7 @@ function MediaAssetDraggable({
startTime: number;
}) => {
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const element = buildElementFromMedia({
mediaId: asset.id,
mediaType: asset.type,
@@ -11,11 +11,9 @@ import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import {
BatchCommand,
AddTrackCommand,
InsertElementCommand,
} from "@/lib/commands";
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
} from "@/constants/transcription-constants";
import type {
TranscriptionLanguage,
TranscriptionProgress,
@@ -31,6 +29,11 @@ import {
SectionFields,
} from "@/components/section";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
AddTrackCommand,
BatchCommand,
InsertElementCommand,
} from "@/lib/commands";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
@@ -62,7 +65,10 @@ export function Captions() {
});
setProcessingStep("Preparing audio...");
const { samples } = await decodeAudioToFloat32({ audioBlob });
const { samples } = await decodeAudioToFloat32({
audioBlob,
sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
});
const result = await transcriptionService.transcribe({
audioData: samples,
@@ -8,10 +8,10 @@ import {
SectionTitle,
} from "@/components/section";
import {
BLUR_INTENSITY_PRESETS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_COLOR,
} from "@/constants/project-constants";
BACKGROUND_BLUR_INTENSITY_PRESETS,
DEFAULT_BACKGROUND_BLUR_INTENSITY,
DEFAULT_BACKGROUND_COLOR,
} from "@/lib/background/constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
@@ -164,16 +164,16 @@ export function BackgroundContent() {
const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity
: DEFAULT_BLUR_INTENSITY;
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color
: DEFAULT_COLOR;
: DEFAULT_BACKGROUND_COLOR;
const canvasSize = activeProject.settings.canvasSize;
const blurPreviews = useMemo(
() =>
BLUR_INTENSITY_PRESETS.map((blur) => (
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FPS_PRESETS } from "@/constants/project-constants";
import { FPS_PRESETS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
@@ -28,7 +28,7 @@ import { Tick02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@opencut/ui/icons";
import { OcSquarePlusIcon } from "@/components/icons";
import type { TCanvasSize } from "@/lib/project/types";
type SettingsView = "project-info" | "background";
@@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEditor } from "@/hooks/use-editor";
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
@@ -70,41 +71,25 @@ export function StickersView() {
/>
</div>
<div className="mt-2 flex min-h-0 flex-1 flex-col">
<div className="border-b border-border px-2">
<div
role="tablist"
aria-label="Sticker categories"
className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
>
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
const isActive = key === selectedCategory;
return (
<button
key={key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() =>
setSelectedCategory({
category: key as StickerCategory,
})
}
className={cn(
"text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
isActive && "text-primary border-primary",
)}
>
{label}
</button>
);
})}
</div>
</div>
<Tabs
value={selectedCategory}
onValueChange={(value) => {
setSelectedCategory({ category: value as StickerCategory });
}}
variant="underline"
className="mt-2 flex min-h-0 flex-1 flex-col"
>
<TabsList aria-label="Sticker categories">
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
<TabsTrigger key={key} value={key}>
{label}
</TabsTrigger>
))}
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
<StickersContentView />
</div>
</div>
</Tabs>
</div>
);
}
@@ -17,7 +17,7 @@ import {
screenToCanvas,
} from "@/lib/preview/preview-coords";
import { clamp, isNearlyEqual } from "@/utils/math";
import { PREVIEW_ZOOM } from "@/constants/editor-constants";
import { PREVIEW_ZOOM } from "@/lib/preview/constants";
const MIDDLE_MOUSE_BUTTON = 1;
const IS_AT_FIT_EPSILON = 0.001;
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
@@ -22,7 +22,7 @@ import {
SelectItem,
SelectSeparator,
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/constants";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/stores/preview-store";
@@ -93,11 +93,7 @@ function TimecodeDisplay() {
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
{formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
</span>
</div>
);
@@ -9,8 +9,7 @@ export function useElementPlayhead({
startTime: number;
duration: number;
}) {
const editor = useEditor();
const playheadTime = editor.playback.getCurrentTime();
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
@@ -3,6 +3,7 @@
import { useEditor } from "@/hooks/use-editor";
import {
buildGraphicParamPath,
coerceAnimationValueForParam,
getKeyframeAtTime,
getParamDefaultInterpolation,
getParamValueKind,
@@ -78,14 +79,15 @@ export function useKeyframedParamProperty({
propertyPath,
time: localTime,
value,
valueKind: getParamValueKind({ param }),
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
numericRange:
param.type === "number"
? { min: param.min, max: param.max, step: param.step }
: undefined,
coerceValue: (nextValue) =>
coerceAnimationValueForParam({
param,
value: nextValue,
}),
}),
},
},
@@ -30,7 +30,7 @@ import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
import { MasksTab } from "./tabs/masks-tab";
import { SpeedTab } from "./tabs/speed-tab";
import { GraphicTab } from "./tabs/graphic-tab";
import { OcShapesIcon } from "@opencut/ui/icons";
import { OcShapesIcon } from "@/components/icons";
export type TabContentProps = {
trackId: string;
@@ -1,5 +1,7 @@
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
clamp,
@@ -10,6 +12,7 @@ import {
} from "@/utils/math";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import { resolveNumberAtTime } from "@/lib/animation";
import { useEditor } from "@/hooks/use-editor";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { KeyframeToggle } from "../components/keyframe-toggle";
@@ -34,6 +37,7 @@ export function AudioTab({
element: AudioElement | VideoElement;
trackId: string;
}) {
const editor = useEditor();
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
@@ -80,46 +84,68 @@ export function AudioTab({
rightValue: DEFAULTS.element.volume,
})
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
const isSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
return (
<Section collapsible sectionKey={`${element.id}:audio`}>
<SectionHeader>
<SectionTitle>Audio</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
label="Volume"
beforeLabel={
<KeyframeToggle
isActive={volume.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle volume keyframe"
onToggle={volume.toggleKeyframe}
/>
<>
{isSeparated && (
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
<p className="text-sm">Audio has been separated.</p>
<Button
className="mt-3"
size="sm"
variant="secondary"
onClick={() =>
editor.timeline.toggleSourceAudioSeparation({
trackId,
elementId: element.id,
})
}
>
<NumberField
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
value={volume.displayValue}
onFocus={volume.onFocus}
onChange={volume.onChange}
onBlur={volume.onBlur}
dragSensitivity="slow"
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
onScrub={volume.scrubTo}
onScrubEnd={volume.commitScrub}
onReset={() =>
volume.commitValue({
value: DEFAULTS.element.volume,
})
Recover audio
</Button>
</div>
)}
<Section collapsible sectionKey={`${element.id}:audio`}>
<SectionHeader>
<SectionTitle>Audio</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
label="Volume"
beforeLabel={
<KeyframeToggle
isActive={volume.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle volume keyframe"
onToggle={volume.toggleKeyframe}
/>
}
isDefault={isDefault}
suffix="dB"
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
>
<NumberField
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
value={volume.displayValue}
onFocus={volume.onFocus}
onChange={volume.onChange}
onBlur={volume.onBlur}
dragSensitivity="slow"
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
onScrub={volume.scrubTo}
onScrubEnd={volume.commitScrub}
onReset={() =>
volume.commitValue({
value: DEFAULTS.element.volume,
})
}
isDefault={isDefault}
suffix="dB"
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
</>
);
}
@@ -1,7 +1,7 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { OcCheckerboardIcon } from "@/components/icons";
import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/hooks/use-menu-preview";
import {
@@ -53,7 +53,7 @@ import {
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import { cn } from "@/utils/ui";
type MasksTabProps = {
@@ -11,7 +11,7 @@ import {
MAX_RETIME_RATE,
clampRetimeRate,
canMaintainPitch,
} from "@/constants/retime-constants";
} from "@/lib/retime/rate";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import {
Section,
@@ -18,13 +18,13 @@ import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
CORNER_RADIUS_MAX,
DEFAULT_TEXT_COLOR,
CORNER_RADIUS_MIN,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
} from "@/lib/text/constants";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
@@ -38,7 +38,7 @@ import {
PlusSignIcon,
TextFontIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { cn } from "@/utils/ui";
@@ -343,7 +343,7 @@ function BackgroundSection({
trackId: string;
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
@@ -6,11 +6,11 @@ import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import {
DEFAULT_BOOKMARK_COLOR,
TIMELINE_BOOKMARK_ROW_HEIGHT,
} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
DEFAULT_TIMELINE_BOOKMARK_COLOR,
} from "./theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { getSnappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@@ -83,12 +83,12 @@ export function TimelineBookmarksRow({
return (
<div
className="relative flex-1 overflow-hidden"
style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }}
style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
>
<button
className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
height: TIMELINE_BOOKMARK_ROW_HEIGHT,
height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
@@ -153,7 +153,7 @@ function TimelineBookmark({
const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR;
const handleSeek = () => seekToBookmarkTime({ editor, time });
@@ -298,12 +298,16 @@ function BookmarkPopoverContent({
}) {
const editor = useEditor();
const [draftColorHex, setDraftColorHex] = useState(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
(bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
.replace("#", "")
.toUpperCase(),
);
useEffect(() => {
setDraftColorHex(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
(bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
.replace("#", "")
.toUpperCase(),
);
}, [bookmark.color]);
@@ -357,7 +361,7 @@ function BookmarkPopoverContent({
/>
{bookmark.color &&
bookmark.color.replace(/^#/, "").toUpperCase() !==
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
<Button
type="button"
variant="text"
@@ -1,6 +1,6 @@
import { getDropLineY } from "@/lib/timeline/drop-utils";
import { getDropLineY } from "./drop-target";
import type { TimelineTrack, DropTarget } from "@/lib/timeline";
import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
import { TIMELINE_LAYERS } from "./layers";
interface DragLineProps {
dropTarget: DropTarget | null;
@@ -0,0 +1,251 @@
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
import { TIMELINE_TRACK_GAP_PX } from "./layout";
import { getTrackHeight } from "./track-layout";
function findElementAtPosition({
mouseX,
tracks,
trackIndex,
targetElementTypes,
pixelsPerSecond,
zoomLevel,
}: {
mouseX: number;
tracks: TimelineTrack[];
trackIndex: number;
targetElementTypes: string[];
pixelsPerSecond: number;
zoomLevel: number;
}): { elementId: string; trackId: string } | null {
const time = mouseX / (pixelsPerSecond * zoomLevel);
const track = tracks[trackIndex];
if (!track || !("elements" in track)) return null;
const hit = track.elements.find(
(element: TimelineElement) =>
targetElementTypes.includes(element.type) &&
element.startTime <= time &&
time < element.startTime + element.duration,
);
if (!hit) return null;
return { elementId: hit.id, trackId: track.id };
}
function getTrackAtY({
mouseY,
tracks,
verticalDragDirection,
}: {
mouseY: number;
tracks: TimelineTrack[];
verticalDragDirection?: "up" | "down" | null;
}): { trackIndex: number; relativeY: number } | null {
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
const trackHeight = getTrackHeight({ type: tracks[i].type });
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
if (mouseY >= trackTop && mouseY < trackBottom) {
return {
trackIndex: i,
relativeY: mouseY - trackTop,
};
}
if (i < tracks.length - 1 && verticalDragDirection) {
const gapTop = trackBottom;
const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX;
if (mouseY >= gapTop && mouseY < gapBottom) {
const isDraggingUp = verticalDragDirection === "up";
return {
trackIndex: isDraggingUp ? i : i + 1,
relativeY: isDraggingUp ? trackHeight - 1 : 0,
};
}
}
cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
}
return null;
}
const EMPTY_TARGET_ELEMENT = null;
function fallbackNewTrackDropTarget({
xPosition,
}: {
xPosition: number;
}): DropTarget {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
export function computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
playheadTime,
isExternalDrop,
elementDuration,
pixelsPerSecond,
zoomLevel,
verticalDragDirection,
startTimeOverride,
excludeElementId,
targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
? startTimeOverride
: isExternalDrop
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
if (tracks.length === 0) {
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
createNewTrackOnly: true,
},
});
const emptyTimelineResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!emptyTimelineResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: emptyTimelineResult.insertIndex,
isNewTrack: true,
insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
hoverDirection: isAboveAllTracks ? "above" : "below",
createNewTrackOnly: true,
},
});
const outOfBoundsResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!outOfBoundsResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: outOfBoundsResult.insertIndex,
isNewTrack: true,
insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
if (targetElementTypes && targetElementTypes.length > 0) {
const targetElement = findElementAtPosition({
mouseX,
tracks,
trackIndex,
targetElementTypes,
pixelsPerSecond,
zoomLevel,
});
if (targetElement) {
return {
trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition,
targetElement,
};
}
}
const trackHeight = getTrackHeight({ type: track.type });
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex,
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
verticalDragDirection,
},
});
if (!placementResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
if (placementResult.kind === "existingTrack") {
const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
return {
trackIndex: placementResult.trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
return {
trackIndex: placementResult.insertIndex,
isNewTrack: true,
insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
export function getDropLineY({
dropTarget,
tracks,
}: {
dropTarget: DropTarget;
tracks: TimelineTrack[];
}): number {
const safeTrackIndex = Math.min(
Math.max(dropTarget.trackIndex, 0),
tracks.length,
);
let y = 0;
for (let i = 0; i < safeTrackIndex; i++) {
y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
}
return y;
}
@@ -13,7 +13,7 @@ import {
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
import {
ContextMenu,
ContextMenuContent,
@@ -38,21 +38,27 @@ import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/lib/timeline";
import {
TIMELINE_CONSTANTS,
TRACK_GAP,
TIMELINE_SCROLLBAR_SIZE_PX,
} from "@/constants/timeline-constants";
TIMELINE_CONTENT_TOP_PADDING_PX,
TIMELINE_TRACK_GAP_PX,
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
} from "./layout";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
canTrackHaveAudio,
canTrackBeHidden,
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import {
getTrackHeight,
getCumulativeHeightBefore,
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden,
isMainTrack,
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/lib/timeline";
} from "./track-layout";
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
import { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
@@ -67,7 +73,7 @@ import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing";
import { resolveTimelineElementIntersections } from "./selection-hit-testing";
import { cn } from "@/utils/ui";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
@@ -230,7 +236,7 @@ export function Timeline() {
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
const clamped =
Math.sign(raw) *
Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX);
Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX);
tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
} else {
tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
@@ -335,7 +341,7 @@ export function Timeline() {
const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
containerWidth,
zoomLevel,
@@ -383,7 +389,7 @@ export function Timeline() {
const timelineHeaderHeight =
(timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
TIMELINE_CONSTANTS.PADDING_TOP_PX || 0;
TIMELINE_CONTENT_TOP_PADDING_PX || 0;
return (
<section
@@ -483,7 +489,7 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({ tracks }),
),
) + TIMELINE_CONSTANTS.PADDING_TOP_PX
) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`,
}}
onMouseDown={(event) => {
@@ -577,7 +583,10 @@ function TrackLabelsPanel({
);
return (
<div className="flex w-28 shrink-0 flex-col border-r">
<div
className="flex shrink-0 flex-col border-r"
style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }}
>
<div
className="shrink-0"
style={{ height: timelineHeaderHeight || 48 }}
@@ -585,21 +594,24 @@ function TrackLabelsPanel({
<div ref={trackLabelsRef} className="flex-1 overflow-hidden">
<div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
{tracks.length > 0 && (
<div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}>
<div
className="flex flex-col"
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
>
{tracks.map((track) => (
<div
key={track.id}
className={cn(
"group flex items-center px-3",
tracksWithSelection.has(track.id) &&
TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
SELECTED_TRACK_ROW_CLASS,
)}
style={{
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{canTracktHaveAudio(track) && (
{canTrackHaveAudio(track) && (
<TrackToggleIcon
isOff={track.muted}
icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
@@ -707,10 +719,10 @@ function TimelineTrackRows({
className={cn(
"absolute right-0 left-0 transition-colors",
tracksWithSelection.has(track.id) &&
TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
SELECTED_TRACK_ROW_CLASS,
)}
style={{
top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
@@ -753,7 +765,7 @@ function TimelineTrackRows({
timeline.toggleTrackMute({ trackId: track.id });
}}
>
{canTracktHaveAudio(track) && track.muted
{canTrackHaveAudio(track) && track.muted
? "Unmute track"
: "Mute track"}
</ContextMenuItem>
@@ -0,0 +1,4 @@
export const TIMELINE_DRAG_THRESHOLD_PX = 5;
export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40;
export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7;
export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15;
@@ -0,0 +1,6 @@
export const TIMELINE_LAYERS = {
trackContent: 10,
dragLine: 20,
playhead: 30,
snapIndicator: 40,
} as const;
@@ -0,0 +1,16 @@
import type { TrackType } from "@/lib/timeline";
export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
video: 65,
text: 25,
audio: 50,
graphic: 25,
effect: 25,
} as const;
export const TIMELINE_TRACK_GAP_PX = 6;
export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
export const TIMELINE_RULER_HEIGHT_PX = 22;
export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16;
export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
export const TIMELINE_CONTENT_TOP_PADDING_PX = 2;
@@ -1,6 +1,9 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
import {
TIMELINE_CONTENT_TOP_PADDING_PX,
} from "./layout";
import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout";
type TimelineElementRef = { trackId: string; elementId: string };
@@ -93,7 +96,7 @@ export function resolveTimelineElementIntersections({
startPos,
endPos: currentPos,
});
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
const selectedElements: TimelineElementRef[] = [];
for (const [trackIndex, track] of tracks.entries()) {
@@ -102,7 +105,7 @@ export function resolveTimelineElementIntersections({
trackIndex,
});
const trackHeight = getTrackHeight({ type: track.type });
const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop;
const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
@@ -6,7 +6,7 @@ import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
import { TIMELINE_LAYERS } from "./layers";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
zoomLevel: number;
@@ -0,0 +1,29 @@
import type { TrackType } from "@/lib/timeline";
export const TIMELINE_TRACK_THEME: Record<
TrackType,
{
elementClassName: string;
waveformColor?: string;
}
> = {
video: { elementClassName: "transparent" },
text: { elementClassName: "bg-[#5DBAA0]" },
audio: {
elementClassName: "bg-[#8F5DBA]",
waveformColor: "rgba(255, 255, 255, 0.5)",
},
graphic: { elementClassName: "bg-[#BA5D7A]" },
effect: { elementClassName: "bg-[#5d93ba]" },
} as const;
export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
export function getTimelineElementClassName({
type,
}: {
type: TrackType;
}): string {
return TIMELINE_TRACK_THEME[type].elementClassName.trim();
}
@@ -12,8 +12,6 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
getElementClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
hasElementEffects,
@@ -21,7 +19,11 @@ import {
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
import { getTrackHeight } from "./track-layout";
import {
getTimelineElementClassName,
TIMELINE_TRACK_THEME,
} from "./theme";
import {
ContextMenu,
ContextMenuContent,
@@ -39,6 +41,11 @@ import type {
} from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import {
canToggleSourceAudio,
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import {
getActionDefinition,
type TAction,
@@ -61,7 +68,9 @@ import {
Search01Icon,
Exchange01Icon,
KeyframeIcon,
Link02Icon,
MagicWand05Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
@@ -280,6 +289,16 @@ export function TimelineElement({
};
const isMuted = canElementHaveAudio(element) && element.muted === true;
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
canToggleSourceAudio(element, mediaAsset);
const sourceAudioLabel =
element.type === "video"
? getSourceAudioActionLabel({ element })
: "Extract audio";
const isElementSourceAudioSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
return (
<ContextMenu>
@@ -334,6 +353,21 @@ export function TimelineElement({
isMuted={isMuted}
/>
)}
{canToggleCurrentSourceAudio && (
<ContextMenuItem
icon={
<HugeiconsIcon
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction("toggle-source-audio");
}}
>
{sourceAudioLabel}
</ContextMenuItem>
)}
{canElementBeHidden(element) && (
<VisibilityMenuItem
element={element}
@@ -429,7 +463,7 @@ function ElementInner({
<div
className={cn(
"absolute inset-0 overflow-hidden rounded-sm",
getElementClasses({ type: track.type }),
getTimelineElementClassName({ type: track.type }),
isReducedOpacity && "opacity-50",
)}
>
@@ -566,7 +600,7 @@ function KeyframeIndicators({
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 mt-1.5 text-black",
"size-3.5 text-black",
isIndicatorSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
@@ -675,7 +709,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
<AudioWaveform
audioBuffer={audioBuffer}
audioUrl={audioUrl}
color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
color={TIMELINE_TRACK_THEME.audio.waveformColor}
/>
<MediaElementHeader name={mediaLabel} hasFade={false} />
</div>
@@ -9,9 +9,9 @@ import {
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
import {
TIMELINE_LAYERS,
TIMELINE_SCROLLBAR_SIZE_PX,
} from "@/constants/timeline-constants";
} from "./layout";
import { TIMELINE_LAYERS } from "./layers";
interface TimelinePlayheadProps {
zoomLevel: number;
@@ -1,9 +1,9 @@
import { type JSX, useLayoutEffect, useRef } from "react";
import {
TIMELINE_CONSTANTS,
TIMELINE_RULER_HEIGHT,
} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
@@ -31,7 +31,7 @@ export function TimelineRuler({
handleRulerMouseDown,
}: TimelineRulerProps) {
const duration = useEditor((e) => e.timeline.getTotalDuration());
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
const fps =
@@ -107,7 +107,7 @@ export function TimelineRuler({
aria-valuemax={effectiveDuration}
aria-valuenow={0}
className="relative flex-1 overflow-x-visible"
style={{ height: TIMELINE_RULER_HEIGHT }}
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
onWheel={handleWheel}
onClick={(event) => {
// Ruler seek already happens on mousedown via playhead scrubbing.
@@ -124,7 +124,7 @@ export function TimelineRuler({
ref={rulerRef}
className="relative cursor-default select-none"
style={{
height: TIMELINE_RULER_HEIGHT,
height: TIMELINE_RULER_HEIGHT_PX,
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
@@ -1,4 +1,5 @@
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
TooltipProvider,
Tooltip,
@@ -6,7 +7,6 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { SplitSquareHorizontal } from "lucide-react";
import {
SplitButton,
SplitButtonLeft,
@@ -14,10 +14,19 @@ import {
SplitButtonSeparator,
} from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
TIMELINE_ZOOM_BUTTON_FACTOR,
} from "./interaction";
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view";
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
import {
canToggleSourceAudio,
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import { hasMediaId } from "@/lib/timeline";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -33,9 +42,14 @@ import {
Copy01Icon,
AlignLeftIcon,
AlignRightIcon,
Link02Icon,
Layers01Icon,
Chart03Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
export function TimelineToolbar({
zoomLevel,
@@ -50,10 +64,10 @@ export function TimelineToolbar({
const newZoomLevel =
direction === "in"
? Math.min(
TIMELINE_CONSTANTS.ZOOM_MAX,
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
TIMELINE_ZOOM_MAX,
zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR,
)
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel });
};
@@ -76,9 +90,44 @@ export function TimelineToolbar({
}
function ToolbarLeftSection() {
const editor = useEditor();
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
const { selectedElements } = useElementSelection();
const isCurrentlyBookmarked = useEditor((e) =>
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
);
const selectedElement =
selectedElements.length === 1
? (editor.timeline.getElementsWithTracks({
elements: selectedElements,
})[0] ?? null)
: null;
const selectedMediaAsset = (() => {
if (!selectedElement) {
return null;
}
const { element } = selectedElement;
if (!hasMediaId(element)) {
return null;
}
return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
})();
const canToggleSelectedSourceAudio =
!!selectedElement &&
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
const sourceAudioLabel =
selectedElement?.element.type === "video"
? getSourceAudioActionLabel({
element: selectedElement.element,
})
: "Extract audio";
const isSelectedSourceAudioSeparated =
selectedElement?.element.type === "video" &&
isSourceAudioSeparated({
element: selectedElement.element,
});
const handleAction = ({
action,
@@ -115,10 +164,16 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
icon={<SplitSquareHorizontal />}
tooltip="Separate audio (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
icon={
<HugeiconsIcon
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
tooltip={sourceAudioLabel}
disabled={!canToggleSelectedSourceAudio}
onClick={({ event }) =>
handleAction({ action: "toggle-source-audio", event })
}
/>
<ToolbarButton
@@ -156,6 +211,14 @@ function ToolbarLeftSection() {
}
/>
</Tooltip>
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Chart03Icon} />}
tooltip="Open graph editor"
onClick={() => {}}
/>
</Tooltip>
</TooltipProvider>
</div>
);
@@ -207,7 +270,7 @@ function ToolbarRightSection({
/>
<ToolbarButton
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
icon={<OcRippleIcon size={24} className="scale-110" />}
isActive={rippleEditingEnabled}
tooltip="Ripple editing"
onClick={() => toggleRippleEditing()}
@@ -252,27 +315,34 @@ function ToolbarButton({
onClick,
disabled,
isActive,
buttonWrapper,
}: {
icon: React.ReactNode;
tooltip: string;
onClick: ({ event }: { event: React.MouseEvent }) => void;
onClick?: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean;
isActive?: boolean;
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
}) {
const button = (
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={onClick ? (event) => onClick({ event }) : undefined}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
);
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
{buttonWrapper ? buttonWrapper(button) : button}
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
@@ -5,7 +5,8 @@ import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/lib/timeline";
import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
import { TIMELINE_LAYERS } from "./layers";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
@@ -59,7 +60,7 @@ export function TimelineTrackContent({
getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
});
return (
@@ -0,0 +1,37 @@
import type { TrackType } from "@/lib/timeline";
import {
TIMELINE_TRACK_GAP_PX,
TIMELINE_TRACK_HEIGHTS_PX,
} from "./layout";
export function getTrackHeight({ type }: { type: TrackType }): number {
return TIMELINE_TRACK_HEIGHTS_PX[type];
}
export function getCumulativeHeightBefore({
tracks,
trackIndex,
}: {
tracks: Array<{ type: TrackType }>;
trackIndex: number;
}): number {
return tracks
.slice(0, trackIndex)
.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }) + TIMELINE_TRACK_GAP_PX,
0,
);
}
export function getTotalTracksHeight({
tracks,
}: {
tracks: Array<{ type: TrackType }>;
}): number {
const tracksHeight = tracks.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }),
0,
);
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
return tracksHeight + gapsHeight;
}
@@ -1,4 +1,6 @@
export function OcVercelIcon({ className }: { className?: string }) {
import type { IconProps } from "./types";
export function OcVercelIcon({ className }: IconProps) {
return (
<svg
className={className}
@@ -17,10 +19,7 @@ export function OcVercelIcon({ className }: { className?: string }) {
export function OcMarbleIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -43,10 +42,7 @@ export function OcMarbleIcon({
export function OcDataBuddyIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+5
View File
@@ -0,0 +1,5 @@
export interface IconProps {
size?: number;
className?: string;
strokeWidth?: number;
}
@@ -1,10 +1,9 @@
import type { IconProps } from "./types";
export function OcVideoIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -34,10 +33,7 @@ export function OcVideoIcon({
export function OcCheckerboardIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -68,10 +64,7 @@ export function OcCheckerboardIcon({
export function OcSlidersVerticalIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -113,10 +106,7 @@ export function OcSlidersVerticalIcon({
export function OcSocialIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round(size * (38 / 24))}
@@ -162,10 +152,7 @@ export function OcSocialIcon({
export function OcTextWidthIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round((size * 17) / 24)}
@@ -190,10 +177,7 @@ export function OcTextWidthIcon({
export function OcTextHeightIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round((size * 20) / 23)}
@@ -218,10 +202,7 @@ export function OcTextHeightIcon({
export function OcFontIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -244,11 +225,7 @@ export function OcShapesIcon({
className = "",
size = 32,
strokeWidth = 1.5,
}: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -288,10 +265,7 @@ export function OcShapesIcon({
export function OcMirrorIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -329,11 +303,7 @@ export function OcSquarePlusIcon({
className = "",
size = 32,
strokeWidth = 1.5,
}: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -383,3 +353,52 @@ export function OcSquarePlusIcon({
</svg>
);
}
export function OcRippleIcon({
className = "",
size = 24,
strokeWidth = 1.5,
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className={className}
>
<title>Ripple</title>
<path
d="M5 9C4.39316 9.58984 2 11.1597 2 12C2 12.8403 4.39316 14.4102 5 15"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 9C19.6068 9.58984 22 11.1597 22 12C22 12.8403 19.6068 14.4102 19 15"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.42285 11.9795H21.868"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.1454 21.7021L12.1454 2.25692"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+51 -21
View File
@@ -1,9 +1,20 @@
"use client";
import { type PropsWithChildren, useEffect, useRef, useState } from "react";
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
type HandlebarsProps = PropsWithChildren;
const MIN_HANDLE_SEPARATION_PX = 60;
const MASK_GRADIENT_EDGE_PADDING_PX = 10;
const HANDLEBARS_ROTATE_DEG = 2.76;
const RIGHT_HANDLE_LEFT_OFFSET_PX = -30;
export function Handlebars({ children }: HandlebarsProps) {
const containerRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null);
@@ -31,18 +42,18 @@ export function Handlebars({ children }: HandlebarsProps) {
initialPosition: 0,
});
useEffect(() => {
const el = containerRef.current;
if (!el) return;
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateWidth = () => {
const newWidth = el.offsetWidth;
const newWidth = container.offsetWidth;
setWidth(newWidth);
setRightHandle(newWidth);
};
const observer = new ResizeObserver(updateWidth);
observer.observe(el);
observer.observe(container);
updateWidth();
return () => observer.disconnect();
@@ -66,7 +77,10 @@ export function Handlebars({ children }: HandlebarsProps) {
const deltaX = event.clientX - startX;
if (side === "left") {
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
const maxLeft = Math.max(
0,
rightHandlePositionRef.current - MIN_HANDLE_SEPARATION_PX,
);
const nextLeftHandle = Math.max(
0,
Math.min(maxLeft, initialPosition + deltaX),
@@ -77,7 +91,7 @@ export function Handlebars({ children }: HandlebarsProps) {
const minRight = Math.min(
widthRef.current,
leftHandlePositionRef.current + 60,
leftHandlePositionRef.current + MIN_HANDLE_SEPARATION_PX,
);
const nextRightHandle = Math.max(
minRight,
@@ -106,13 +120,30 @@ export function Handlebars({ children }: HandlebarsProps) {
};
}, []);
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
const rightGradientPercent =
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
const hasMeasuredWidth = width > 0;
const leftGradientPercent = hasMeasuredWidth
? (leftHandle / (width - MASK_GRADIENT_EDGE_PADDING_PX)) * 100
: 0;
const rightGradientPercent = hasMeasuredWidth
? (rightHandle / (width + MASK_GRADIENT_EDGE_PADDING_PX)) * 100
: 100;
const textMask = hasMeasuredWidth
? `linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`
: undefined;
return (
<div className="flex justify-center gap-4 leading-16">
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
<div
ref={containerRef}
className="relative mt-0.5"
style={{ transform: `rotate(-${HANDLEBARS_ROTATE_DEG}deg)` }}
>
<div className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500">
<div
ref={leftHandleRef}
@@ -135,9 +166,13 @@ export function Handlebars({ children }: HandlebarsProps) {
<div
ref={rightHandleRef}
className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
className="bg-background absolute z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
style={{
translate: `${rightHandle}px 0`,
left: hasMeasuredWidth
? `${RIGHT_HANDLE_LEFT_OFFSET_PX}px`
: undefined,
right: hasMeasuredWidth ? undefined : "0px",
translate: hasMeasuredWidth ? `${rightHandle}px 0` : undefined,
}}
onPointerDown={(event) => {
event.preventDefault();
@@ -156,13 +191,8 @@ export function Handlebars({ children }: HandlebarsProps) {
<span
className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto"
style={{
mask: `linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`,
mask: textMask,
WebkitMask: textMask,
}}
>
{children}
@@ -9,6 +9,7 @@ import { useKeybindingsListener } from "@/hooks/use-keybindings";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
interface EditorProviderProps {
projectId: string;
@@ -33,6 +34,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
const loadProject = async () => {
try {
setIsLoading(true);
await initializeGpuRenderer();
await editor.project.loadProject({ id: projectId });
if (cancelled) return;
+3 -3
View File
@@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
variant === "default" &&
"data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
"border border-transparent data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
variant === "underline" &&
"text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
"text-muted-foreground data-[state=active]:text-primary border-x-0 border-t-0 border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
className,
)}
{...props}
+4 -4
View File
@@ -16,12 +16,12 @@ export const FPS_PRESETS = [
] as const;
export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] = [
{ label: "Light", value: 10 },
{ label: "Medium", value: 50 },
{ label: "Heavy", value: 100 },
{ label: "Light", value: 50 },
{ label: "Medium", value: 250 },
{ label: "Heavy", value: 500 },
] as const;
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
export const DEFAULT_FPS = 30;
export const DEFAULT_BLUR_INTENSITY = 10;
export const DEFAULT_BLUR_INTENSITY = 50;
export const DEFAULT_COLOR = "#000000";
+1 -1
View File
@@ -1,4 +1,4 @@
import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
export const SITE_URL = "https://opencut.app";
+1 -11
View File
@@ -1,4 +1,4 @@
import type { ElementType, TrackType } from "@/lib/timeline";
import type { TrackType } from "@/lib/timeline";
export const TRACK_CONFIG: Record<
TrackType,
@@ -46,16 +46,6 @@ export const ELEMENT_TYPE_CONFIG: Record<
effect: { background: "bg-[#5d93ba]" },
} as const;
export const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
audio: "audio",
text: "text",
sticker: "graphic",
graphic: "graphic",
effect: "effect",
video: "video",
image: "video",
};
export const TRACK_GAP = 6;
export const TRACK_LABELS_WIDTH_PX = 112;
@@ -51,6 +51,8 @@ export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId =
"whisper-small";
export const DEFAULT_TRANSCRIPTION_SAMPLE_RATE = 16000;
export const DEFAULT_CHUNK_LENGTH_SECONDS = 30;
export const DEFAULT_STRIDE_SECONDS = 5;
+11 -1
View File
@@ -10,6 +10,7 @@ import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
import { registerDefaultMasks } from "@/lib/masks";
import { isMainTrack } from "@/lib/timeline/placement";
export class EditorCore {
private static instance: EditorCore | null = null;
@@ -27,7 +28,7 @@ export class EditorCore {
private constructor() {
registerDefaultEffects();
registerDefaultMasks();
this.command = new CommandManager();
this.command = new CommandManager(this);
this.timeline = new TimelineManager(this);
this.playback = new PlaybackManager(this);
this.scenes = new ScenesManager(this);
@@ -37,6 +38,15 @@ export class EditorCore {
this.save = new SaveManager(this);
this.audio = new AudioManager(this);
this.selection = new SelectionManager(this);
this.command.registerReactor(() => {
const tracks = this.timeline.getTracks();
const prunedTracks = tracks.filter(
(track) => track.elements.length > 0 || isMainTrack(track),
);
if (prunedTracks.length !== tracks.length) {
this.timeline.updateTracks(prunedTracks);
}
});
this.save.start();
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
import {
clampRetimeRate,
shouldMaintainPitch,
} from "@/constants/retime-constants";
} from "@/lib/retime/rate";
import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
import {
+81 -14
View File
@@ -1,37 +1,82 @@
import type { Command } from "@/lib/commands";
import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
import type { ElementRef } from "@/lib/timeline/types";
interface CommandHistoryEntry {
command: Command;
previousSelection: ElementRef[];
selectionOverride?: ElementRef[];
}
export class CommandManager {
private history: Command[] = [];
private redoStack: Command[] = [];
private history: CommandHistoryEntry[] = [];
private redoStack: CommandHistoryEntry[] = [];
private reactors: Array<() => void> = [];
constructor(private editor: EditorCore) {}
execute({ command }: { command: Command }): Command {
command.execute();
this.history.push(command);
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
this.history.push({
command,
previousSelection,
selectionOverride,
});
this.redoStack = [];
return command;
}
push({ command }: { command: Command }): void {
this.history.push(command);
this.history.push({
command,
previousSelection: this.getSelectionSnapshot(),
});
this.redoStack = [];
}
registerReactor(reactor: () => void): void {
this.reactors.push(reactor);
}
undo(): void {
if (this.history.length === 0) return;
const command = this.history.pop();
command?.undo();
if (command) {
this.redoStack.push(command);
const entry = this.history.pop();
entry?.command.undo();
if (entry) {
// Only restore selection for commands that explicitly changed it.
// Commands without selection intent leave selection untouched,
// preserving any UI-driven selection changes (clicks, box select)
// that happened between commands. Commands that remove elements
// must declare { select: [] } to clear stale refs.
if (entry.selectionOverride !== undefined) {
this.editor.selection.setSelectedElements({
elements: [...entry.previousSelection],
});
}
this.redoStack.push(entry);
}
}
redo(): void {
if (this.redoStack.length === 0) return;
const command = this.redoStack.pop();
command?.redo();
if (command) {
this.history.push(command);
const entry = this.redoStack.pop();
if (!entry) {
return;
}
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
this.history.push({
command: entry.command,
previousSelection,
selectionOverride,
});
}
canUndo(): boolean {
@@ -46,4 +91,26 @@ export class CommandManager {
this.history = [];
this.redoStack = [];
}
private getSelectionSnapshot(): ElementRef[] {
return [...this.editor.selection.getSelectedElements()];
}
private applySelectionOverride(
result: CommandResult | undefined,
): ElementRef[] | undefined {
if (result?.select === undefined) {
return undefined;
}
const selectionOverride = [...result.select];
this.editor.selection.setSelectedElements({ elements: selectionOverride });
return selectionOverride;
}
private runReactors(): void {
for (const reactor of this.reactors) {
reactor();
}
}
}
@@ -12,11 +12,9 @@ import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
import {
DEFAULT_FPS,
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
} from "@/constants/project-constants";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/constants";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { buildScene } from "@/services/renderer/scene-builder";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
@@ -29,7 +27,7 @@ import {
import { loadFonts } from "@/lib/fonts/google-fonts";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps";
import { getRaisedProjectFpsForImportedMedia } from "@/lib/fps/utils";
import type { MediaAsset } from "@/lib/media/types";
export interface MigrationState {
@@ -101,7 +99,7 @@ export class ProjectManager {
originalCanvasSize: null,
background: {
type: "color",
color: DEFAULT_COLOR,
color: DEFAULT_BACKGROUND_COLOR,
},
},
version: CURRENT_PROJECT_VERSION,
@@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@@ -107,10 +107,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" };
}
const timecode = formatTimeCode({
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
+1 -1
View File
@@ -12,7 +12,7 @@ import {
getFrameTime,
isBookmarkAtTime,
} from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import { ensureMainTrack } from "@/lib/timeline/placement";
import {
CreateSceneCommand,
DeleteSceneCommand,
+17 -2
View File
@@ -13,7 +13,7 @@ import type {
AnimationValue,
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "@/lib/time";
import { getLastFrameTime } from "opencut-wasm";
import {
AddTrackCommand,
RemoveTrackCommand,
@@ -45,6 +45,7 @@ import {
ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
@@ -265,7 +266,7 @@ export class TimelineManager {
time: number;
clipboardItems: ClipboardItem[];
}): { trackId: string; elementId: string }[] {
const command = new PasteCommand(time, clipboardItems);
const command = new PasteCommand({ time, clipboardItems });
this.editor.command.execute({ command });
return command.getPastedElements();
}
@@ -281,6 +282,20 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
toggleSourceAudioSeparation({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
const command = new ToggleSourceAudioSeparationCommand({
trackId,
elementId,
});
this.editor.command.execute({ command });
}
updateElements({
updates,
pushHistory = true,
@@ -6,9 +6,13 @@ import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { getElementsAtTime } from "@/lib/timeline";
import {
getElementsAtTime,
hasMediaId,
} from "@/lib/timeline";
import { cancelInteraction } from "@/lib/cancel-interaction";
import { invokeAction } from "@/lib/actions";
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
import {
activateScope,
clearActiveScope,
@@ -261,7 +265,43 @@ export function useEditorActions() {
elements: selectedElements,
rippleEnabled: rippleEditingEnabled,
});
editor.selection.clearSelection();
},
undefined,
);
useActionHandler(
"toggle-source-audio",
() => {
if (selectedElements.length !== 1) {
return;
}
const selectedElement = editor.timeline.getElementsWithTracks({
elements: selectedElements,
})[0];
if (!selectedElement) {
return;
}
const mediaAsset = (() => {
const { element } = selectedElement;
if (!hasMediaId(element)) {
return null;
}
return (
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
null
);
})();
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
return;
}
editor.timeline.toggleSourceAudioSeparation({
trackId: selectedElement.track.id,
elementId: selectedElement.element.id,
});
},
undefined,
);
@@ -10,12 +10,10 @@ import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
@@ -70,7 +68,7 @@ function getClickOffsetTime({
zoomLevel: number;
}): number {
const clickOffsetX = clientX - elementRect.left;
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
}
function getVerticalDragDirection({
@@ -136,7 +134,7 @@ function getDragDropTarget({
playheadTime: snappedTime,
isExternalDrop: false,
elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel,
startTimeOverride: snappedTime,
excludeElementId: movingElement.id,
@@ -288,7 +286,10 @@ export function useElementInteraction({
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
if (
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
) {
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
@@ -302,10 +303,7 @@ export function useElementInteraction({
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = snapTimeToFrame({
time: adjustedTime,
fps: activeProject.settings.fps,
});
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
@@ -419,7 +417,10 @@ export function useElementInteraction({
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
if (
deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
deltaY <= TIMELINE_DRAG_THRESHOLD_PX
) {
mouseDownLocationRef.current = null;
endDrag();
onSnapPointChange?.(null);
@@ -599,7 +600,10 @@ export function useElementInteraction({
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
if (
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
) {
mouseDownLocationRef.current = null;
return;
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@@ -190,7 +190,7 @@ export function useTimelineElementResize({
const deltaX = clientX - resizing.startX;
let deltaTime =
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = editor.project.getActive().settings.fps;
@@ -285,26 +285,14 @@ export function useTimelineElementResize({
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(
maxAllowed,
Math.max(minTrimStartForNeighbor, calculated),
),
fps: projectFps,
});
const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -326,14 +314,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@@ -359,16 +341,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newStartTime = snapTimeToFrame({
time:
resizing.initialStartTime +
(resizing.initialDuration - newDuration),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -395,13 +369,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: Math.min(
baseDuration + extensionNeeded,
maxAllowedDuration,
),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -411,10 +379,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = snapTimeToFrame({
time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -438,18 +403,12 @@ export function useTimelineElementResize({
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
@@ -6,13 +6,12 @@ import {
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { getKeyframeById } from "@/lib/animation";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import {
DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
import type { SelectedKeyframeRef } from "@/lib/animation/types";
@@ -64,7 +63,7 @@ export function useKeyframeDrag({
const activeProject = editor.project.getActive();
const fps = activeProject.settings.fps;
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const endDrag = useCallback(() => {
setDragState(initialDragState);
@@ -86,10 +85,11 @@ export function useKeyframeDrag({
deltaTime: number;
}) => {
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
const channel = element.animations?.channels[keyframeRef.propertyPath];
const keyframe = channel?.keyframes.find(
(keyframe) => keyframe.id === keyframeRef.keyframeId,
);
const keyframe = getKeyframeById({
animations: element.animations,
propertyPath: keyframeRef.propertyPath,
keyframeId: keyframeRef.keyframeId,
});
if (!keyframe) return [];
const nextTime = Math.max(
0,
@@ -127,7 +127,7 @@ export function useKeyframeDrag({
const handleMouseMove = ({ clientX }: MouseEvent) => {
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
if (deltaX <= DRAG_THRESHOLD_PX) return;
if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
const pending = pendingDragRef.current;
pendingDragRef.current = null;
@@ -248,17 +248,14 @@ export function useKeyframeDrag({
const wasDrag =
mouseDownXRef.current !== null &&
Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
Math.abs(event.clientX - mouseDownXRef.current) >
TIMELINE_DRAG_THRESHOLD_PX;
mouseDownXRef.current = null;
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({
rawTime: displayedStartTime + indicatorTime,
duration,
fps,
});
const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {
@@ -7,8 +7,8 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
@@ -131,7 +131,10 @@ export function useBookmarkDrag({
const deltaX = Math.abs(event.clientX - startMouseX);
const deltaY = Math.abs(event.clientY - startMouseY);
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
if (
deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
deltaY <= TIMELINE_DRAG_THRESHOLD_PX
) {
return;
}
@@ -145,10 +148,7 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({
time: Math.max(0, Math.min(mouseTime, duration)),
fps: activeProject.settings.fps,
});
const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@@ -176,10 +176,7 @@ export function useBookmarkDrag({
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({
time: clampedTime,
fps: activeProject.settings.fps,
});
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/components/editor/panels/timeline/layout";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
@@ -44,7 +44,8 @@ export function useSnapIndicatorPosition({
time: snapPoint?.time ?? 0,
zoomLevel,
});
const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
const leftPosition =
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
return {
leftPosition,
@@ -3,8 +3,9 @@ import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@@ -14,9 +15,9 @@ import {
} from "@/lib/timeline/element-utils";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/timeline/track-utils";
import { isMainTrack } from "@/lib/timeline/placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
@@ -82,14 +83,14 @@ export function useTimelineDragDrop({
elementType === "sticker" ||
elementType === "effect"
) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
}
if (mediaId) {
const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
}
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
},
[editor],
);
@@ -158,7 +159,7 @@ export function useTimelineDragDrop({
playheadTime: currentTime,
isExternalDrop: isExternal,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel,
targetElementTypes,
});
@@ -330,7 +331,7 @@ export function useTimelineDragDrop({
dragData.mediaType === "audio" ? "audio" : "video";
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const element = buildElementFromMedia({
mediaId: mediaAsset.id,
mediaType: mediaAsset.type,
@@ -444,7 +445,8 @@ export function useTimelineDragDrop({
if (!createdAsset) continue;
const duration =
createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
createdAsset.duration ??
DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
@@ -466,7 +468,7 @@ export function useTimelineDragDrop({
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel,
});
@@ -1,4 +1,4 @@
import { getSnappedSeekTime } from "@/lib/time";
import { getSnappedSeekTime } from "opencut-wasm";
import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@@ -8,7 +8,7 @@ import {
getCenteredLineLeft,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
interface UseTimelinePlayheadProps {
zoomLevel: number;
@@ -67,7 +67,7 @@ export function useTimelinePlayhead({
const relativeMouseX = event.clientX - rulerRect.left;
const timelineContentWidth =
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const clampedMouseX = Math.max(
0,
@@ -78,16 +78,12 @@ export function useTimelinePlayhead({
0,
Math.min(
duration,
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({
rawTime,
duration,
fps: framesPerSecond,
});
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
@@ -165,7 +161,7 @@ export function useTimelinePlayhead({
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
});
useEffect(() => {
@@ -252,7 +248,7 @@ export function useTimelinePlayhead({
if (!rulerViewport || !tracksViewport) return;
const playheadPixels =
time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
const viewportWidth = rulerViewport.clientWidth;
const scrollMinimum = 0;
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
@@ -1,7 +1,7 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getSnappedSeekTime } from "@/lib/time";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { getSnappedSeekTime } from "opencut-wasm";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
@@ -132,16 +132,12 @@ export function useTimelineSeek({
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({
rawTime,
duration,
fps: projectFps,
});
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
seek(time);
editor.project.setTimelineViewState({
viewState: {
@@ -7,7 +7,14 @@ import {
useRef,
useState,
} from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD,
} from "@/components/editor/panels/timeline/interaction";
import {
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
@@ -30,7 +37,7 @@ interface UseTimelineZoomReturn {
export function useTimelineZoom({
containerRef,
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
minZoom = TIMELINE_ZOOM_MIN,
initialZoom,
initialScrollLeft,
initialPlayheadTime,
@@ -49,7 +56,7 @@ export function useTimelineZoom({
hasInitializedRef.current = true;
return Math.max(
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom),
Math.min(TIMELINE_ZOOM_MAX, initialZoom),
);
}
return minZoom;
@@ -91,7 +98,7 @@ export function useTimelineZoom({
setZoomLevel((prev) => {
const nextZoom = Math.max(
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor),
Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
);
return nextZoom;
});
@@ -105,7 +112,7 @@ export function useTimelineZoom({
if (initialZoom !== undefined && !hasInitializedRef.current) {
hasInitializedRef.current = true;
setZoomLevel(
Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)),
Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)),
);
return;
}
@@ -126,7 +133,7 @@ export function useTimelineZoom({
: zoomLevelOrUpdater;
const clampedZoom = Math.max(
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
Math.min(TIMELINE_ZOOM_MAX, nextZoom),
);
return clampedZoom;
});
@@ -153,12 +160,12 @@ export function useTimelineZoom({
});
const isCrossingThresholdUp =
previousSliderPercent <
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const isCrossingThresholdDown =
previousSliderPercent >=
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const syncScroll = (scrollLeft: number) => {
scrollElement.scrollLeft = scrollLeft;
@@ -178,11 +185,11 @@ export function useTimelineZoom({
isInPlayheadAnchorModeRef.current = true;
}
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
+2 -2
View File
@@ -7,7 +7,7 @@ import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
@@ -74,7 +74,7 @@ export function usePasteMedia() {
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
+13 -3
View File
@@ -19,6 +19,7 @@ import {
import { isVisualElement } from "@/lib/timeline/element-utils";
import {
getElementLocalTime,
hasKeyframesForPath,
resolveTransformAtTime,
setChannel,
} from "@/lib/animation";
@@ -213,8 +214,14 @@ export function useTransformHandles({
const baseWidth = bounds.width / resolvedTransform.scaleX;
const baseHeight = bounds.height / resolvedTransform.scaleY;
const shouldClearScaleAnimation =
!!element.animations?.channels["transform.scaleX"] ||
!!element.animations?.channels["transform.scaleY"];
hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.scaleX",
}) ||
hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.scaleY",
});
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
animations: setChannel({
@@ -325,7 +332,10 @@ export function useTransformHandles({
? "transform.scaleX"
: "transform.scaleY";
const shouldClearScaleAnimation =
!!element.animations?.channels[propertyPath];
hasKeyframesForPath({
animations: element.animations,
propertyPath,
});
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
animations: element.animations,
+4
View File
@@ -101,6 +101,10 @@ export const ACTIONS = {
description: "Toggle ripple editing",
category: "editing",
},
"toggle-source-audio": {
description: "Extract or recover source audio",
category: "editing",
},
"select-all": {
description: "Select all elements",
category: "selection",
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import {
composeAnimationValue,
createAnimationBinding,
} from "@/lib/animation/binding-values";
describe("binding values", () => {
test("formats composed animated colors as hex", () => {
const binding = createAnimationBinding({
path: "color",
kind: "color",
});
expect(
composeAnimationValue({
binding,
componentValues: {
r: 1,
g: 0,
b: 0,
a: 1,
},
}),
).toBe("#ff0000");
});
});
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test";
import {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
} from "@/lib/animation/keyframe-query";
import type {
ElementAnimations,
ScalarAnimationKey,
} from "@/lib/animation/types";
function createScalarKey({
id,
time,
value,
}: {
id: string;
time: number;
value: number;
}): ScalarAnimationKey {
return {
id,
time,
value,
segmentToNext: "linear",
tangentMode: "flat",
};
}
function buildPositionAnimations({
xKeys,
yKeys,
}: {
xKeys: ScalarAnimationKey[];
yKeys: ScalarAnimationKey[];
}): ElementAnimations {
return {
bindings: {
"transform.position": {
path: "transform.position",
kind: "vector2",
components: [
{ key: "x", channelId: "transform.position:x" },
{ key: "y", channelId: "transform.position:y" },
],
},
},
channels: {
"transform.position:x": {
kind: "scalar",
keys: xKeys,
},
"transform.position:y": {
kind: "scalar",
keys: yKeys,
},
},
};
}
describe("keyframe query", () => {
test("returns keyframes from any component channel", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
});
expect(
getElementKeyframes({ animations }).map(({ id, time }) => ({
id,
time,
})),
).toEqual([
{ id: "x-1", time: 1 },
{ id: "y-2", time: 2 },
]);
});
test("finds a keyframe at time on a non-primary component", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
});
expect(
getKeyframeAtTime({
animations,
propertyPath: "transform.position",
time: 2,
}),
).toMatchObject({
id: "y-2",
time: 2,
});
});
test("finds a keyframe by id on a non-primary component", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
});
expect(
getKeyframeById({
animations,
propertyPath: "transform.position",
keyframeId: "y-2",
}),
).toMatchObject({
id: "y-2",
time: 2,
value: { x: 10, y: 20 },
});
});
test("prefers the primary component when multiple components share a time", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })],
});
expect(
getElementKeyframes({ animations }).map(({ id, time }) => ({
id,
time,
})),
).toEqual([{ id: "x-1", time: 1 }]);
expect(
getKeyframeAtTime({
animations,
propertyPath: "transform.position",
time: 1,
}),
).toMatchObject({
id: "x-1",
time: 1,
});
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { ScalarAnimationKey } from "@/lib/animation/types";
export function getBezierPoint({
progress,
p0,
p1,
p2,
p3,
}: {
progress: number;
p0: number;
p1: number;
p2: number;
p3: number;
}) {
const mt = 1 - progress;
return (
mt * mt * mt * p0 +
3 * mt * mt * progress * p1 +
3 * mt * progress * progress * p2 +
progress * progress * progress * p3
);
}
export function getDefaultRightHandle({
leftKey,
rightKey,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
const span = rightKey.time - leftKey.time;
const valueDelta = rightKey.value - leftKey.value;
return {
dt: span / 3,
dv: valueDelta / 3,
};
}
export function getDefaultLeftHandle({
leftKey,
rightKey,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
const span = rightKey.time - leftKey.time;
const valueDelta = rightKey.value - leftKey.value;
return {
dt: -span / 3,
dv: -valueDelta / 3,
};
}
export function solveBezierProgressForTime({
time,
leftKey,
rightKey,
}: {
time: number;
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
let lower = 0;
let upper = 1;
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
for (let iteration = 0; iteration < 20; iteration++) {
const mid = (lower + upper) / 2;
const estimate = getBezierPoint({
progress: mid,
p0: leftKey.time,
p1: leftKey.time + rightHandle.dt,
p2: rightKey.time + leftHandle.dt,
p3: rightKey.time,
});
if (estimate < time) {
lower = mid;
} else {
upper = mid;
}
}
return (lower + upper) / 2;
}
@@ -0,0 +1,335 @@
import { converter, formatHex, formatHex8, parse } from "culori";
import type {
AnimationBindingComponent,
AnimationBindingOfKind,
AnimationBindingInstance,
AnimationBindingKind,
ColorAnimationBinding,
DiscreteAnimationBinding,
NumberAnimationBinding,
AnimationPath,
AnimationValue,
DiscreteValue,
Vector2AnimationBinding,
VectorValue,
} from "@/lib/animation/types";
interface LinearRgba {
r: number;
g: number;
b: number;
a: number;
}
export type AnimationComponentValue = number | DiscreteValue;
const toRgb = converter("rgb");
function clamp01({ value }: { value: number }): number {
return Math.max(0, Math.min(1, value));
}
function srgbToLinear({ value }: { value: number }): number {
return value <= 0.04045
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb({ value }: { value: number }): number {
const clamped = clamp01({ value });
return clamped <= 0.0031308
? clamped * 12.92
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isVectorValue(value: unknown): value is VectorValue {
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
}
export function getBindingComponentKeys({
kind,
}: {
kind: AnimationBindingKind;
}): string[] {
if (kind === "vector2") {
return ["x", "y"];
}
if (kind === "color") {
return ["r", "g", "b", "a"];
}
return ["value"];
}
export function buildBindingChannelId({
path,
componentKey,
}: {
path: AnimationPath;
componentKey: string;
}): string {
return `${path}:${componentKey}`;
}
function createBindingComponent<TKey extends string>({
path,
key,
}: {
path: AnimationPath;
key: TKey;
}): AnimationBindingComponent<TKey> {
return {
key,
channelId: buildBindingChannelId({ path, componentKey: key }),
};
}
function cloneBindingComponents<TKey extends string>({
components,
}: {
components: AnimationBindingComponent<TKey>[];
}): AnimationBindingComponent<TKey>[] {
return components.map((component) => ({ ...component }));
}
const animationBindingFactories = {
color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
path,
kind: "color",
colorSpace: "srgb-linear",
components: [
createBindingComponent({ path, key: "r" }),
createBindingComponent({ path, key: "g" }),
createBindingComponent({ path, key: "b" }),
createBindingComponent({ path, key: "a" }),
],
}),
vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
path,
kind: "vector2",
components: [
createBindingComponent({ path, key: "x" }),
createBindingComponent({ path, key: "y" }),
],
}),
number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
path,
kind: "number",
components: [createBindingComponent({ path, key: "value" })],
}),
discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
path,
kind: "discrete",
components: [createBindingComponent({ path, key: "value" })],
}),
} satisfies {
[K in AnimationBindingKind]: ({
path,
}: {
path: AnimationPath;
}) => AnimationBindingOfKind<K>;
};
export function createAnimationBinding<TKind extends AnimationBindingKind>({
path,
kind,
}: {
path: AnimationPath;
kind: TKind;
}): AnimationBindingOfKind<TKind>;
export function createAnimationBinding({
path,
kind,
}: {
path: AnimationPath;
kind: AnimationBindingKind;
}): AnimationBindingInstance {
return animationBindingFactories[kind]({ path });
}
const animationBindingCloners = {
color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
vector2: ({
binding,
}: {
binding: Vector2AnimationBinding;
}): Vector2AnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
number: ({
binding,
}: {
binding: NumberAnimationBinding;
}): NumberAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
discrete: ({
binding,
}: {
binding: DiscreteAnimationBinding;
}): DiscreteAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
} satisfies {
[K in AnimationBindingKind]: ({
binding,
}: {
binding: AnimationBindingOfKind<K>;
}) => AnimationBindingOfKind<K>;
};
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
binding,
}: {
binding: AnimationBindingOfKind<TKind>;
}): AnimationBindingOfKind<TKind>;
export function cloneAnimationBinding({
binding,
}: {
binding: AnimationBindingInstance;
}): AnimationBindingInstance {
switch (binding.kind) {
case "color":
return animationBindingCloners.color({ binding });
case "vector2":
return animationBindingCloners.vector2({ binding });
case "number":
return animationBindingCloners.number({ binding });
case "discrete":
return animationBindingCloners.discrete({ binding });
}
}
export function parseColorToLinearRgba({
color,
}: {
color: string;
}): LinearRgba | null {
const parsed = parse(color);
const rgb = parsed ? toRgb(parsed) : null;
if (!rgb) {
return null;
}
return {
r: srgbToLinear({ value: rgb.r ?? 0 }),
g: srgbToLinear({ value: rgb.g ?? 0 }),
b: srgbToLinear({ value: rgb.b ?? 0 }),
a: clamp01({ value: rgb.alpha ?? 1 }),
};
}
export function formatLinearRgba({
color,
}: {
color: LinearRgba;
}): string {
const rgb = {
mode: "rgb",
r: linearToSrgb({ value: color.r }),
g: linearToSrgb({ value: color.g }),
b: linearToSrgb({ value: color.b }),
alpha: clamp01({ value: color.a }),
} as const;
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
}
export function decomposeAnimationValue({
kind,
value,
}: {
kind: AnimationBindingKind;
value: AnimationValue;
}): Record<string, AnimationComponentValue> | null {
if (kind === "number") {
return typeof value === "number" ? { value } : null;
}
if (kind === "vector2") {
return isVectorValue(value) ? { x: value.x, y: value.y } : null;
}
if (kind === "color") {
if (typeof value !== "string") {
return null;
}
const parsed = parseColorToLinearRgba({ color: value });
if (!parsed) {
return null;
}
return {
r: parsed.r,
g: parsed.g,
b: parsed.b,
a: parsed.a,
};
}
return typeof value === "string" || typeof value === "boolean"
? { value }
: null;
}
export function composeAnimationValue({
binding,
componentValues,
}: {
binding: AnimationBindingInstance;
componentValues: Record<string, AnimationComponentValue | undefined>;
}): AnimationValue | null {
if (binding.kind === "number") {
const value = componentValues.value;
return typeof value === "number" ? value : null;
}
if (binding.kind === "vector2") {
const x = componentValues.x;
const y = componentValues.y;
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
}
if (binding.kind === "color") {
const r = componentValues.r;
const g = componentValues.g;
const b = componentValues.b;
const a = componentValues.a;
if (
typeof r !== "number" ||
typeof g !== "number" ||
typeof b !== "number" ||
typeof a !== "number"
) {
return null;
}
return formatLinearRgba({
color: {
r,
g,
b,
a,
},
});
}
const value = componentValues.value;
return typeof value === "string" || typeof value === "boolean" ? value : null;
}
@@ -1,19 +0,0 @@
import type {
AnimationPropertyPath,
ColorAnimationChannel,
ElementAnimations,
} from "@/lib/animation/types";
export function getColorChannelForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): ColorAnimationChannel | undefined {
const channel = animations?.channels[propertyPath];
if (!channel || channel.valueKind !== "color") {
return undefined;
}
return channel;
}
@@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types";
import type {
ElementAnimations,
EffectParamPath,
NumberAnimationChannel,
} from "@/lib/animation/types";
import {
getChannel,
removeKeyframe,
setChannel,
upsertKeyframe,
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
@@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
const channel = getChannel({ animations, propertyPath: path });
if (channel && channel.keyframes.length > 0) {
resolved[paramKey] = getChannelValueAtTime({
channel,
time: localTime,
fallbackValue: staticValue,
}) as number | string | boolean;
} else {
resolved[paramKey] = staticValue;
}
resolved[paramKey] = animations?.bindings[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
localTime,
fallbackValue: staticValue,
})
: staticValue;
}
return resolved;
}
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
valueKind: "number",
keyframes: [],
};
export function upsertEffectParamKeyframe({
animations,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const targetChannel =
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value,
interpolation: interpolation ?? "linear",
keyframeId,
});
return (
setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
export function removeEffectParamKeyframe({
animations,
effectId,
@@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
paramKey: string;
keyframeId: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const updatedChannel = removeKeyframe({ channel, keyframeId });
return setChannel({
return removeElementKeyframe({
animations,
propertyPath: path,
channel: updatedChannel,
propertyPath: buildEffectParamPath({ effectId, paramKey }),
keyframeId,
});
}
@@ -7,8 +7,7 @@ import {
getGraphicDefinition,
resolveGraphicParams,
} from "@/lib/graphics";
import { getChannel } from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
@@ -58,19 +57,16 @@ export function resolveGraphicParamsAtTime({
for (const param of definition.params) {
const path = buildGraphicParamPath({ paramKey: param.key });
const channel = getChannel({
animations: element.animations,
propertyPath: path,
});
if (!channel || channel.keyframes.length === 0) {
if (!element.animations?.bindings[path]) {
continue;
}
resolved[param.key] = getChannelValueAtTime({
channel,
time: Math.max(0, localTime),
resolved[param.key] = resolveAnimationPathValueAtTime({
animations: element.animations,
propertyPath: path,
localTime: Math.max(0, localTime),
fallbackValue: baseParams[param.key] ?? param.default,
}) as number | string | boolean;
});
}
return resolved;
+15 -5
View File
@@ -1,7 +1,8 @@
export {
getChannelValueAtTime,
getNumberChannelValueAtTime,
getVectorChannelValueAtTime,
getDiscreteChannelValueAtTime,
getScalarChannelValueAtTime,
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
@@ -19,6 +20,7 @@ export {
export {
getElementLocalTime,
resolveAnimationPathValueAtTime,
resolveColorAtTime,
resolveNumberAtTime,
resolveOpacityAtTime,
@@ -34,12 +36,12 @@ export {
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
type NumericRange,
withElementBaseValueForProperty,
} from "./property-registry";
export {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
@@ -51,8 +53,17 @@ export {
resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
buildEffectParamPath,
isEffectParamPath,
parseEffectParamPath,
removeEffectParamKeyframe,
resolveEffectParamsAtTime,
} from "./effect-param-channel";
export {
isAnimationPath,
coerceAnimationValueForParam,
resolveAnimationTarget,
getParamValueKind,
getParamDefaultInterpolation,
@@ -66,6 +77,5 @@ export {
} from "./property-groups";
export {
getVectorChannelForPath,
isVectorValue,
} from "./vector-channel";
} from "./binding-values";
+255 -286
View File
@@ -1,15 +1,20 @@
import type {
AnimationChannel,
AnimationInterpolation,
AnimationValue,
ColorAnimationChannel,
DiscreteValue,
DiscreteAnimationChannel,
NumberAnimationChannel,
VectorAnimationChannel,
VectorValue,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
function byTimeAscending({
leftTime,
@@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number {
return Math.max(0, Math.min(1, value));
}
function parseHexChannel({ hex }: { hex: string }): number | null {
const value = Number.parseInt(hex, 16);
return Number.isNaN(value) ? null : value;
}
function parseHexColor({
color,
}: {
color: string;
}): { red: number; green: number; blue: number; alpha: number } | null {
const trimmed = color.trim();
if (!trimmed.startsWith("#")) {
return null;
}
const rawHex = trimmed.slice(1);
if (rawHex.length === 3 || rawHex.length === 4) {
const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
const red = parseHexChannel({ hex: `${redHex}${redHex}` });
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
if (red === null || green === null || blue === null || alpha === null) {
return null;
}
return { red, green, blue, alpha: alpha / 255 };
}
if (rawHex.length === 6 || rawHex.length === 8) {
const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
const alpha = parseHexChannel({ hex: alphaHex });
if (red === null || green === null || blue === null || alpha === null) {
return null;
}
return { red, green, blue, alpha: alpha / 255 };
}
return null;
}
function formatRgbaColor({
red,
green,
blue,
alpha,
}: {
red: number;
green: number;
blue: number;
alpha: number;
}): string {
const roundedRed = Math.round(red);
const roundedGreen = Math.round(green);
const roundedBlue = Math.round(blue);
const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
}
function lerpNumber({
leftValue,
rightValue,
@@ -115,43 +57,99 @@ function lerpNumber({
return leftValue + (rightValue - leftValue) * progress;
}
function interpolateColor({
leftColor,
rightColor,
progress,
function normalizeRightHandle({
handle,
leftKey,
rightKey,
}: {
leftColor: string;
rightColor: string;
progress: number;
}): string {
const leftParsed = parseHexColor({ color: leftColor });
const rightParsed = parseHexColor({ color: rightColor });
if (!leftParsed || !rightParsed) {
return progress >= 1 ? rightColor : leftColor;
handle: ScalarAnimationKey["rightHandle"];
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
if (!handle) {
return undefined;
}
return formatRgbaColor({
red: lerpNumber({
leftValue: leftParsed.red,
rightValue: rightParsed.red,
progress,
}),
green: lerpNumber({
leftValue: leftParsed.green,
rightValue: rightParsed.green,
progress,
}),
blue: lerpNumber({
leftValue: leftParsed.blue,
rightValue: rightParsed.blue,
progress,
}),
alpha: lerpNumber({
leftValue: leftParsed.alpha,
rightValue: rightParsed.alpha,
progress,
}),
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
return {
dt: Math.min(span, Math.max(0, handle.dt)),
dv: handle.dv,
};
}
function normalizeLeftHandle({
handle,
leftKey,
rightKey,
}: {
handle: ScalarAnimationKey["leftHandle"];
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
if (!handle) {
return undefined;
}
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
return {
dt: Math.max(-span, Math.min(0, handle.dt)),
dv: handle.dv,
};
}
function normalizeScalarKey({
key,
}: {
key: ScalarAnimationKey;
}): ScalarAnimationKey {
return {
...key,
tangentMode: key.tangentMode ?? "flat",
segmentToNext: key.segmentToNext ?? "linear",
};
}
function normalizeScalarChannel({
channel,
}: {
channel: ScalarAnimationChannel;
}): ScalarAnimationChannel {
const sortedKeys = [...channel.keys]
.map((key) => normalizeScalarKey({ key }))
.sort((leftKey, rightKey) =>
byTimeAscending({
leftTime: leftKey.time,
rightTime: rightKey.time,
}),
);
const nextKeys = sortedKeys.map((key, index) => {
const previousKey = sortedKeys[index - 1];
const nextKey = sortedKeys[index + 1];
return {
...key,
leftHandle:
previousKey != null
? normalizeLeftHandle({
handle: key.leftHandle,
leftKey: previousKey,
rightKey: key,
})
: undefined,
rightHandle:
nextKey != null
? normalizeRightHandle({
handle: key.rightHandle,
leftKey: key,
rightKey: nextKey,
})
: undefined,
};
});
return {
...channel,
keys: nextKeys,
};
}
export function normalizeChannel<TChannel extends AnimationChannel>({
@@ -159,9 +157,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
}: {
channel: TChannel;
}): TChannel {
if (channel.kind === "scalar") {
return normalizeScalarChannel({
channel,
}) as TChannel;
}
return {
...channel,
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
byTimeAscending({
leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time,
@@ -170,171 +174,152 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
} as TChannel;
}
function evaluateChannelValueAtTime<
TKeyframe extends { time: number; value: TValue },
TValue,
>({
keyframes,
function extrapolateScalarEdge({
mode,
edgeKey,
neighborKey,
time,
fallbackValue,
getInterpolatedValue,
}: {
keyframes: TKeyframe[] | undefined;
mode: "hold" | "linear";
edgeKey: ScalarAnimationKey;
neighborKey: ScalarAnimationKey | undefined;
time: number;
fallbackValue: TValue;
getInterpolatedValue: ({
leftKeyframe,
rightKeyframe,
progress,
}: {
leftKeyframe: TKeyframe;
rightKeyframe: TKeyframe;
progress: number;
}) => TValue;
}): TValue {
if (!keyframes || keyframes.length === 0) {
return fallbackValue;
}) {
if (mode === "hold" || !neighborKey) {
return edgeKey.value;
}
const firstKeyframe = keyframes[0];
const lastKeyframe = keyframes[keyframes.length - 1];
if (!firstKeyframe || !lastKeyframe) {
return fallbackValue;
const span = neighborKey.time - edgeKey.time;
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
return edgeKey.value;
}
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
return firstKeyframe.value;
}
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
return lastKeyframe.value;
}
for (
let keyframeIndex = 0;
keyframeIndex < keyframes.length - 1;
keyframeIndex++
) {
const leftKeyframe = keyframes[keyframeIndex];
const rightKeyframe = keyframes[keyframeIndex + 1];
if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
return rightKeyframe.value;
}
const isBetweenPair = isWithinTimePair({
time,
leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time,
});
if (!isBetweenPair) {
continue;
}
const span = rightKeyframe.time - leftKeyframe.time;
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
return rightKeyframe.value;
}
const progress = clamp01({
value: (time - leftKeyframe.time) / span,
});
return getInterpolatedValue({
leftKeyframe,
rightKeyframe,
progress,
});
}
return lastKeyframe.value;
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
}
export function getNumberChannelValueAtTime({
export function getScalarSegmentInterpolation({
segment,
}: {
segment: ScalarSegmentType;
}): AnimationInterpolation {
if (segment === "step") {
return "hold";
}
return segment === "bezier" ? "bezier" : "linear";
}
export function getScalarChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: NumberAnimationChannel | undefined;
channel: ScalarAnimationChannel | undefined;
time: number;
fallbackValue: number;
}): number {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
const normalizedChannel = normalizeChannel({
channel,
});
const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) {
return fallbackValue;
}
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.before ?? "hold",
edgeKey: firstKey,
neighborKey: normalizedChannel.keys[1],
time,
});
}
return firstKey.value;
}
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.after ?? "hold",
edgeKey: lastKey,
neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
time,
});
}
return lastKey.value;
}
for (
let keyIndex = 0;
keyIndex < normalizedChannel.keys.length - 1;
keyIndex++
) {
const leftKey = normalizedChannel.keys[keyIndex];
const rightKey = normalizedChannel.keys[keyIndex + 1];
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
return rightKey.value;
}
if (
!isWithinTimePair({
time,
leftTime: leftKey.time,
rightTime: rightKey.time,
})
) {
continue;
}
if (leftKey.segmentToNext === "step") {
return leftKey.value;
}
const span = rightKey.time - leftKey.time;
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
return rightKey.value;
}
const progress = clamp01({
value: (time - leftKey.time) / span,
});
if (leftKey.segmentToNext === "linear") {
return lerpNumber({
leftValue: leftKeyframe.value,
rightValue: rightKeyframe.value,
leftValue: leftKey.value,
rightValue: rightKey.value,
progress,
});
},
});
}
const curveProgress = solveBezierProgressForTime({
time,
leftKey,
rightKey,
});
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
return getBezierPoint({
progress: curveProgress,
p0: leftKey.value,
p1: leftKey.value + rightHandle.dv,
p2: rightKey.value + leftHandle.dv,
p3: rightKey.value,
});
}
return lastKey.value;
}
export function getColorValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: ColorAnimationChannel | undefined;
time: number;
fallbackValue: string;
}): string {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
return interpolateColor({
leftColor: leftKeyframe.value,
rightColor: rightKeyframe.value,
progress,
});
},
});
}
export function getVectorChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: VectorAnimationChannel | undefined;
time: number;
fallbackValue: VectorValue;
}): VectorValue {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
return {
x:
leftKeyframe.value.x +
(rightKeyframe.value.x - leftKeyframe.value.x) * progress,
y:
leftKeyframe.value.y +
(rightKeyframe.value.y - leftKeyframe.value.y) * progress,
};
},
});
}
function getDiscreteValueAtTime({
export function getDiscreteChannelValueAtTime({
channel,
time,
fallbackValue,
@@ -343,12 +328,21 @@ function getDiscreteValueAtTime({
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
const normalizedChannel = normalizeChannel({
channel,
});
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
if (time + TIME_EPSILON_SECONDS < key.time) {
break;
}
currentValue = key.value;
}
return currentValue;
}
export function getChannelValueAtTime({
@@ -360,50 +354,25 @@ export function getChannelValueAtTime({
time: number;
fallbackValue: AnimationValue;
}): AnimationValue {
if (!channel || channel.keyframes.length === 0) {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
if (channel.valueKind === "number") {
if (typeof fallbackValue !== "number") {
return fallbackValue;
}
return getNumberChannelValueAtTime({
channel,
time,
fallbackValue,
});
}
if (channel.valueKind === "color") {
if (typeof fallbackValue !== "string") {
return fallbackValue;
}
return getColorValueAtTime({
channel,
time,
fallbackValue,
});
}
if (channel.valueKind === "vector") {
if (!isVectorValue(fallbackValue)) {
return fallbackValue;
}
return getVectorChannelValueAtTime({
channel,
time,
fallbackValue: fallbackValue as VectorValue,
});
if (channel.kind === "scalar") {
return typeof fallbackValue === "number"
? getScalarChannelValueAtTime({
channel,
time,
fallbackValue,
})
: fallbackValue;
}
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
return getDiscreteValueAtTime({
return getDiscreteChannelValueAtTime({
channel,
time,
fallbackValue,
+254 -28
View File
@@ -1,11 +1,184 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
AnimationBindingInstance,
AnimationChannel,
AnimationPath,
ElementAnimations,
ElementKeyframe,
} from "@/lib/animation/types";
import {
type AnimationComponentValue,
composeAnimationValue,
} from "./binding-values";
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
} from "./interpolation";
import { isAnimationPath } from "./target-resolver";
function getBindingFallbackValue({
channel,
}: {
channel: ElementAnimations["channels"][string];
}) {
if (!channel || channel.keys.length === 0) {
return channel?.kind === "discrete" ? false : 0;
}
return channel.keys[0].value;
}
interface BindingKeyframeMatch {
componentIndex: number;
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}
function getBindingKeyframeMatches({
animations,
binding,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
}): BindingKeyframeMatch[] {
return binding.components.flatMap((component, componentIndex) => {
const channel = animations.channels[component.channelId];
if (!channel || channel.keys.length === 0) {
return [];
}
return channel.keys.map((keyframe) => ({
componentIndex,
channel,
keyframe,
}));
});
}
function getUniqueBindingKeyframeMatches({
animations,
binding,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
}): BindingKeyframeMatch[] {
const sortedMatches = getBindingKeyframeMatches({
animations,
binding,
}).sort(
(leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex,
);
const uniqueMatches: BindingKeyframeMatch[] = [];
for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
if (
!previousMatch ||
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
TIME_EPSILON_SECONDS
) {
uniqueMatches.push(match);
continue;
}
if (
previousMatch.componentIndex !== 0 &&
match.componentIndex === 0
) {
uniqueMatches[uniqueMatches.length - 1] = match;
}
}
return uniqueMatches;
}
function getPreferredBindingKeyframeMatch({
matches,
}: {
matches: BindingKeyframeMatch[];
}): BindingKeyframeMatch | null {
return (
matches.find((match) => match.componentIndex === 0) ??
matches[0] ??
null
);
}
function getComposedBindingValueAtTime({
animations,
binding,
time,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
time: number;
}) {
const componentValues = Object.fromEntries(
binding.components.map((component) => {
const channel = animations.channels[component.channelId];
return [
component.key,
getChannelValueAtTime({
channel,
time,
fallbackValue: getBindingFallbackValue({ channel }),
}),
];
}),
) as Record<string, AnimationComponentValue | undefined>;
return composeAnimationValue({
binding,
componentValues,
});
}
function getKeyframeInterpolation({
channel,
keyframe,
}: {
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}) {
return channel.kind === "scalar" && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold";
}
function toElementKeyframe({
animations,
binding,
propertyPath,
keyframeMatch,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
propertyPath: AnimationPath;
keyframeMatch: BindingKeyframeMatch;
}): ElementKeyframe | null {
const value = getComposedBindingValueAtTime({
animations,
binding,
time: keyframeMatch.keyframe.time,
});
if (value === null) {
return null;
}
return {
propertyPath,
id: keyframeMatch.keyframe.id,
time: keyframeMatch.keyframe.time,
value,
interpolation: getKeyframeInterpolation({
channel: keyframeMatch.channel,
keyframe: keyframeMatch.keyframe,
}),
};
}
export function getElementKeyframes({
animations,
}: {
@@ -15,23 +188,28 @@ export function getElementKeyframes({
return [];
}
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (
!channel ||
channel.keyframes.length === 0 ||
!isAnimationPath(propertyPath)
) {
return Object.entries(animations.bindings).flatMap(
([propertyPath, binding]) => {
if (!binding || !isAnimationPath(propertyPath)) {
return [];
}
return channel.keyframes.map((keyframe) => ({
propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
interpolation: keyframe.interpolation,
}));
return getUniqueBindingKeyframeMatches({
animations,
binding,
}).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({
animations,
binding,
propertyPath,
keyframeMatch,
});
if (!keyframe) {
return [];
}
return [keyframe];
});
},
);
}
@@ -43,8 +221,14 @@ export function hasKeyframesForPath({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): boolean {
const channel = animations?.channels[propertyPath];
return Boolean(channel && channel.keyframes.length > 0);
const binding = animations?.bindings[propertyPath];
if (!binding) {
return false;
}
return binding.components.some((component) =>
Boolean(animations?.channels[component.channelId]?.keys.length),
);
}
export function getKeyframeAtTime({
@@ -56,17 +240,59 @@ export function getKeyframeAtTime({
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const channel = animations?.channels[propertyPath];
if (!channel || channel.keyframes.length === 0) return null;
const keyframe = channel.keyframes.find(
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
);
if (!keyframe) return null;
return {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return null;
}
const keyframeMatch = getPreferredBindingKeyframeMatch({
matches: getBindingKeyframeMatches({
animations,
binding,
}).filter(({ keyframe }) =>
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
animations,
binding,
propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
interpolation: keyframe.interpolation,
};
keyframeMatch,
});
}
export function getKeyframeById({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
}): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return null;
}
const keyframeMatch = getPreferredBindingKeyframeMatch({
matches: getBindingKeyframeMatches({
animations,
binding,
}).filter(({ keyframe }) => keyframe.id === keyframeId),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
animations,
binding,
propertyPath,
keyframeMatch,
});
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More