mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0997466331 | ||
|
|
1bd9fac203 | ||
|
|
69da5bee0c | ||
|
|
cb99925d75 | ||
|
|
78c6c497f1 | ||
|
|
22725f71fa | ||
|
|
1a8a1e889a | ||
|
|
3516bd69f4 | ||
|
|
57fc0fd323 | ||
|
|
bd9ec023ee | ||
|
|
f4b9f40ab1 | ||
|
|
93cc5d50f3 | ||
|
|
8a1d37cf3e | ||
|
|
e579ae1202 | ||
|
|
b048b739bd | ||
|
|
dc82826951 | ||
|
|
608dc1a62f | ||
|
|
f1d5932c54 | ||
|
|
cc150c7cfb | ||
|
|
f8fb238b22 | ||
|
|
0488a6be45 | ||
|
|
0f5dca4f12 | ||
|
|
154b78c7d1 | ||
|
|
d23be079cc | ||
|
|
70a6d58b84 | ||
|
|
dcf54c1250 | ||
|
|
5cc6abc3a4 | ||
|
|
77a14b1023 | ||
|
|
1c3b22edec | ||
|
|
41562baeb5 | ||
|
|
df207e0f83 | ||
|
|
dc5956900e | ||
|
|
b095862cc0 | ||
|
|
0a6ede73ae | ||
|
|
7ce0018c9b | ||
|
|
9528d0053b | ||
|
|
689853312d | ||
|
|
5a1d9ef3f4 | ||
|
|
eb66e03f0e | ||
|
|
a2418751e6 | ||
|
|
5777a555f6 | ||
|
|
220ec757d4 | ||
|
|
585e28d49b | ||
|
|
1261e663e1 | ||
|
|
c34e01c63e | ||
|
|
3e75852ffe | ||
|
|
e95992b406 | ||
|
|
6df9d70b9b | ||
|
|
f8a0390827 | ||
|
|
d862e56e34 | ||
|
|
d08fbd73e1 | ||
|
|
4d127a038b | ||
|
|
82817cb50c | ||
|
|
a54b3ef076 | ||
|
|
6dd8191f4b | ||
|
|
722dcdd88f | ||
|
|
8db3bead13 | ||
|
|
39ea298a9c | ||
|
|
2c0fd4f86b | ||
|
|
5e2b829f8a | ||
|
|
b09635064a | ||
|
|
6018efc5ac | ||
|
|
7e2a913e13 | ||
|
|
63496d05a2 |
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -22,3 +22,5 @@ bun.lockb
|
||||
.open-next
|
||||
|
||||
target/
|
||||
|
||||
.cargo-tools/
|
||||
Generated
+644
-34
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"apps/desktop",
|
||||
"rust/crates/time",
|
||||
"rust/crates/bridge",
|
||||
"rust/wasm", "rust/crates/gpu",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -8,3 +8,7 @@
|
||||
.next/
|
||||
|
||||
.font-cache/
|
||||
|
||||
next-env.d.ts
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,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,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") {
|
||||
|
||||
Vendored
-6
@@ -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.
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -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";
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
-5
@@ -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;
|
||||
+7
-4
@@ -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"
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
|
||||
import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
|
||||
|
||||
export const SITE_URL = "https://opencut.app";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user