Merge branch 'dev' into ripple-editing

This commit is contained in:
Maze Winther 2026-03-01 01:32:09 +01:00
commit 5e7c27f542
138 changed files with 8791 additions and 1617 deletions

View File

@ -37,6 +37,7 @@ Review every point below carefully to ensure files follow consistent code style
- [ ] Each file has one single purpose/responsibility
- Example: `timeline/index.tsx` should not define `validateElementTrackCompatibility` — that belongs in a lib file
- Example: `lib/timeline-utils.ts` should not declare `TRACK_COLORS` — that belongs in `constants/`
- [ ] File name accurately reflects what the file contains — a misleading name is a bug waiting to happen
- [ ] Business logic lives in either `src/lib`, `src/core` or `src/services` folder
## Comments
@ -133,8 +134,8 @@ Do NOT review by reading the file top-to-bottom and noting what jumps out. Inste
3. Only move to the next section after you've exhausted the current one
4. After all sections are checked, do a final pass: re-read every checklist item and confirm you didn't skip it
Before outputting the table, list each checklist section and confirm you checked it:
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
Before outputting the review, list each checklist section and confirm you checked it:
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | File Names ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
---
@ -142,7 +143,22 @@ Before outputting the table, list each checklist section and confirm you checked
- **ONLY** flag issues that are explicitly covered by a checklist item above.
- Do **NOT** invent your own rules, suggestions, or "nice to haves" as primary issues.
- The review output must be a table of issues, each one mapping to a specific checklist item.
- The review output must be a list of issues, each one mapping to a specific checklist item.
- If something looks off but isn't covered by the checklist, you can mention it as a brief side note at the end — but keep it clearly separate from the actual review. Always default to fixing the issues covered by the checklist above, unless the user says otherwise.
> You WILL miss things if you try to review the whole file in one pass. Iterate rule by rule.
---
## Think Bigger
After the checklist review, step back and ask the hard questions. The biggest architectural problems get solved by the biggest questions.
- Does this abstraction actually need to exist? Could it be deleted entirely?
- Is this the right layer for this logic? (wrong layer = future pain)
- Is this solving a real problem, or a problem we invented?
- Would a simpler data model make this whole file unnecessary?
- Are we adding complexity to work around a bad decision made earlier?
- Could this field be derived from other existing fields? Redundant data in a model is a source of bugs.
Don't be shy about flagging these. A "why does this exist?" question is often worth more than 10 style fixes.

View File

@ -28,7 +28,6 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
### Accessibility (a11y)
- Always include a `title` element for icons unless there's text beside the icon.
- Always include a `type` attribute for button elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.

View File

@ -0,0 +1,45 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
node_modules
.next
.git
.gitignore
*.md
.env*
!.env.example
.cursor
.vscode
diffs
docs
agent-transcripts
mcps
terminals
apps/web/.font-cache

3
.gitignore vendored
View File

@ -13,5 +13,8 @@ node_modules
# cursor
bun.lockb
# content-collections
.content-collections
# Twiggy
.cursor/rules/file-structure.mdc

132
README.md
View File

@ -10,6 +10,18 @@
</tr>
</table>
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
## Why?
- **Privacy**: Your videos stay on your device
@ -38,110 +50,52 @@
### Prerequisites
Before you begin, ensure you have the following installed on your system:
- [Node.js](https://nodejs.org/en/) (v18 or later)
- [Bun](https://bun.sh/docs/installation)
(for `npm` alternative)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to run the frontend or want to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
### Setup
1. Fork the repository
2. Clone your fork locally
3. Navigate to the web app directory: `cd apps/web`
4. Copy `.env.example` to `.env.local`:
1. Fork and clone the repository
2. Copy the environment file:
```bash
# Unix/Linux/Mac
cp .env.example .env.local
# Windows Command Prompt
copy .env.example .env.local
cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell
Copy-Item .env.example .env.local
Copy-Item apps/web/.env.example apps/web/.env.local
```
5. Install dependencies: `bun install`
6. Start the development server: `bun dev`
## Development Setup
### Local Development
1. Start the database and Redis services:
3. Start the database and Redis:
```bash
# From project root
docker-compose up -d
docker compose up -d db redis serverless-redis-http
```
2. Navigate to the web app directory:
4. Install dependencies and start the dev server:
```bash
cd apps/web
bun install
bun dev:web
```
3. Copy `.env.example` to `.env.local`:
```bash
# Unix/Linux/Mac
cp .env.example .env.local
# Windows Command Prompt
copy .env.example .env.local
# Windows PowerShell
Copy-Item .env.example .env.local
```
4. Configure required environment variables in `.env.local`:
**Required Variables:**
```bash
# Database (matches docker-compose.yaml)
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
# Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here"
BETTER_AUTH_URL="http://localhost:3000"
# Redis (matches docker-compose.yaml)
UPSTASH_REDIS_REST_URL="http://localhost:8079"
UPSTASH_REDIS_REST_TOKEN="example_token"
# Marble Blog
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
# Development
NODE_ENV="development"
```
**Generate BETTER_AUTH_SECRET:**
```bash
# Unix/Linux/Mac
openssl rand -base64 32
# Windows PowerShell (simple method)
[System.Web.Security.Membership]::GeneratePassword(32, 0)
# Cross-platform (using Node.js)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Or use an online generator: https://generate-secret.vercel.app/32
```
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
6. Start the development server: `bun run dev` from (inside apps/web)
The application will be available at [http://localhost:3000](http://localhost:3000).
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
### Self-Hosting with Docker
To run everything (including a production build of the app) in Docker:
```bash
docker compose up -d
```
The app will be available at [http://localhost:3100](http://localhost:3100).
## Contributing
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
@ -158,22 +112,6 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
- Follow the setup instructions in CONTRIBUTING.md
- Create a feature branch and submit a PR
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
---
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
## License
[MIT LICENSE](LICENSE)

View File

@ -13,7 +13,7 @@ DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
BETTER_AUTH_SECRET=your_better_auth_secret
UPSTASH_REDIS_REST_URL=http://localhost:8079
UPSTASH_REDIS_REST_TOKEN=example_token_here
UPSTASH_REDIS_REST_TOKEN=example_token
MARBLE_WORKSPACE_KEY=your_workspace_key_here

70
apps/web/Dockerfile Normal file
View File

@ -0,0 +1,70 @@
FROM oven/bun:alpine AS base
FROM base AS builder
WORKDIR /app
ARG FREESOUND_CLIENT_ID
ARG FREESOUND_API_KEY
COPY package.json package.json
COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
COPY packages/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
# Build-time env stubs to pass zod validation
ENV DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
ENV BETTER_AUTH_SECRET="build-time-secret"
ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ENV NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com"
ENV MARBLE_WORKSPACE_KEY="build-placeholder"
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
WORKDIR /app/apps/web
RUN bun run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
RUN chown nextjs:nodejs apps
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "apps/web/server.js"]

View File

@ -0,0 +1,32 @@
import { defineCollection, defineConfig } from "@content-collections/core";
import { z } from "zod";
const changelog = defineCollection({
name: "changelog",
directory: "content/changelog",
include: "*.md",
schema: z.object({
version: z.string(),
date: z.string(),
title: z.string(),
description: z.string().optional(),
changes: z.array(
z.object({
type: z.string(),
text: z.string(),
}),
),
}),
transform: async (doc, { collection }) => {
const allDocs = await collection.documents();
const sorted = [...allDocs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
const isLatest = sorted[0]?.version === doc.version;
return { ...doc, isLatest };
},
});
export default defineConfig({
content: [changelog],
});

View File

@ -0,0 +1,35 @@
---
version: "0.1.0"
date: "2026-02-23"
title: "Editor foundation"
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
changes:
- type: new
text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
- type: new
text: "The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation."
- type: new
text: "New color picker with eyedropper, opacity control, and multiple color formats."
- type: new
text: "Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly."
- type: new
text: "Move, scale, and rotate elements directly in the preview panel."
- type: new
text: "Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now."
- type: new
text: "Paste images, videos, or audio from your clipboard straight into the editor."
- type: new
text: "Hold Shift while moving or resizing to temporarily turn off snapping."
- type: improved
text: "Expanded font selection from 7 to over 1,000."
- type: improved
text: "Fonts load much faster."
- type: improved
text: "All asset panel tabs now share a consistent layout."
- type: improved
text: "Text rendering properly supports multiple lines, line height, and letter spacing."
- type: improved
text: "Better contrast in the dark theme."
- type: fixed
text: "Fixed undo/redo for drag-and-drop so dropped items are reliably undoable."
---

View File

@ -0,0 +1,15 @@
---
version: "0.2.0"
date: "2026-02-29"
title: "Motion & effects"
description: "This release adds the foundation for motion and effects."
changes:
- type: new
text: "Keyframe animation system, starting with transform properties (position, scale, rotation)."
- type: new
text: "Effects system (with our first effect: Blur!)"
- type: fixed
text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below."
- type: fixed
text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio."
---

View File

@ -1,7 +1,16 @@
import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next";
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.glsl": {
loaders: [require.resolve("raw-loader")],
as: "*.js",
},
},
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
@ -46,4 +55,4 @@ const nextConfig: NextConfig = {
},
};
export default withBotId(nextConfig);
export default withContentCollections(withBotId(nextConfig));

View File

@ -1,107 +1,110 @@
{
"name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-primitive": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/culori": "^4.0.1",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"culori": "^4.0.2",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
"react-window": "^2.2.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.2",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
"devDependencies": {
"@napi-rs/canvas": "^0.1.92",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
"name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-primitive": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/culori": "^4.0.1",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"culori": "^4.0.2",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
"react-window": "^2.2.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.2",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
"devDependencies": {
"@content-collections/core": "^0.14.1",
"@content-collections/next": "^0.2.11",
"@napi-rs/canvas": "^0.1.92",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"raw-loader": "^4.0.2",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,142 @@
import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/utils/ui";
import { allChangelogs } from "content-collections";
export const metadata: Metadata = {
title: "Changelog - OpenCut",
description: "Every update, improvement, and fix to OpenCut — documented.",
openGraph: {
title: "Changelog - OpenCut",
description: "Every update, improvement, and fix to OpenCut — documented.",
type: "website",
},
};
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
const knownSectionTitles: Record<string, string> = {
new: "Features",
improved: "Improvements",
fixed: "Fixes",
breaking: "Breaking Changes",
};
function getSectionTitle(type: string): string {
return (
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
);
}
export default function ChangelogPage() {
const releases = [...allChangelogs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
return (
<BasePage title="Changelog" description="See what's new in OpenCut">
<div className="mx-auto w-full max-w-3xl">
<div className="relative">
<div
aria-hidden
className="absolute top-2 bottom-0 left-[5px] w-px bg-border hidden sm:block"
/>
<div className="flex flex-col">
{releases.map((release, releaseIndex) => (
<div key={release.version} className="flex flex-col">
<ReleaseEntry release={release} />
{releaseIndex < releases.length - 1 && (
// ml-1.5 aligns with the center of the 11px timeline dot
<Separator className="my-10 sm:ml-1.5" />
)}
</div>
))}
</div>
</div>
</div>
</BasePage>
);
}
type Change = { type: string; text: string };
type Release = (typeof allChangelogs)[number];
function groupAndOrderChanges({ changes }: { changes: Change[] }) {
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
if (!acc[change.type]) {
acc[change.type] = [];
}
acc[change.type].push(change);
return acc;
}, {});
const customTypes = Object.keys(grouped).filter(
(type) => !knownSectionOrder.includes(type),
);
const orderedTypes = [
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
...customTypes,
];
return { grouped, orderedTypes };
}
function ReleaseEntry({ release }: { release: Release }) {
const { grouped: groupedChanges, orderedTypes } = groupAndOrderChanges({
changes: release.changes,
});
return (
<article className="relative sm:pl-10">
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
<div
className={cn(
"size-[11px] rounded-full border-[1.5px]",
release.isLatest
? "border-foreground bg-foreground"
: "border-muted-foreground/30 bg-background",
)}
/>
</div>
<div className="flex flex-col gap-5">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="text-sm font-medium tracking-widest text-muted-foreground">
{release.version} - {release.date}
</span>
</div>
<div className="flex flex-col gap-4">
<h2 className="text-2xl font-bold tracking-tight">{release.title}</h2>
{release.description && (
<p className="text-base text-foreground leading-relaxed max-w-xl">
{release.description}
</p>
)}
</div>
<div className="flex flex-col gap-4">
{orderedTypes.map((type) => (
<div key={type} className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground">
{getSectionTitle(type)}:
</h3>
<ul className="list-disc pl-5 space-y-1.5">
{groupedChanges[type].map((change) => (
<li
key={change.text}
className="text-base text-foreground leading-relaxed"
>
{change.text}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</article>
);
}

View File

@ -166,7 +166,7 @@ export default function PrivacyPage() {
<a
href="https://www.databuddy.cc"
target="_blank"
rel="noopener"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Databuddy

View File

@ -5,6 +5,8 @@ import { Badge } from "@/components/ui/badge";
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
import { cn } from "@/utils/ui";
const LAST_UPDATED = "February 25, 2026";
type StatusType = "complete" | "pending" | "default" | "info";
interface Status {
@ -47,9 +49,9 @@ const roadmapItems: RoadmapItem[] = [
},
},
{
title: "Badge (potentially)",
title: "Native app (mobile/desktop)",
description:
'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
"Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.",
status: {
text: "Not started",
type: "default",
@ -88,7 +90,7 @@ export default function RoadmapPage() {
return (
<BasePage
title="Roadmap"
description="What's coming next for OpenCut (last updated: July 14, 2025)"
description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`}
>
<div className="mx-auto flex max-w-4xl flex-col gap-16">
<div className="flex flex-col gap-6">

View File

@ -9,6 +9,7 @@ import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
import { TextView } from "./views/text";
import { EffectsView } from "./views/effects";
export function AssetsPanel() {
const { activeTab } = useAssetsPanelStore();
@ -18,11 +19,7 @@ export function AssetsPanel() {
sounds: <SoundsView />,
text: <TextView />,
stickers: <StickersView />,
effects: (
<div className="text-muted-foreground p-4">
Effects view coming soon...
</div>
),
effects: <EffectsView />,
transitions: (
<div className="text-muted-foreground p-4">
Transitions view coming soon...

View File

@ -426,6 +426,9 @@ function GridView({
type: "media",
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
targetElementTypes: ["video", "image"] as const,
}),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
@ -476,6 +479,9 @@ function ListView({
type: "media",
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
targetElementTypes: ["video", "image"] as const,
}),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>

View File

@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
import {
effectPreviewService,
onPreviewImageReady,
} from "@/services/renderer/effect-preview";
import { useEditor } from "@/hooks/use-editor";
import { buildEffectElement } from "@/lib/timeline/element-utils";
import type { EffectDefinition } from "@/types/effects";
export function EffectsView() {
const effects = getAllEffects();
return (
<PanelView title="Effects">
<EffectsGrid effects={effects} />
</PanelView>
);
}
function EffectsGrid({ effects }: { effects: EffectDefinition[] }) {
return (
<div
className="grid gap-2"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }}
>
{effects.map((effect) => (
<EffectItem key={effect.type} effect={effect} />
))}
</div>
);
}
function EffectPreviewCanvas({ effectType }: { effectType: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const render = () => {
if (canvasRef.current) {
effectPreviewService.renderPreview({
effectType,
params: {},
targetCanvas: canvasRef.current,
});
}
};
render();
return onPreviewImageReady({ callback: render });
}, [effectType]);
return <canvas ref={canvasRef} className="size-full" />;
}
function EffectItem({ effect }: { effect: EffectDefinition }) {
const editor = useEditor();
const handleAddToTimeline = useCallback(() => {
const currentTime = editor.playback.getCurrentTime();
const element = buildEffectElement({
effectType: effect.type,
startTime: currentTime,
});
editor.timeline.insertElement({
placement: { mode: "auto", trackType: "effect" },
element,
});
}, [editor, effect.type]);
const preview = <EffectPreviewCanvas effectType={effect.type} />;
return (
<DraggableItem
name={effect.name}
preview={preview}
dragData={{
id: effect.type,
name: effect.name,
type: "effect",
effectType: effect.type,
targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
}}
onAddToTimeline={handleAddToTimeline}
aspectRatio={1}
isRounded
variant="card"
containerClassName="w-full"
/>
);
}

View File

@ -69,6 +69,7 @@ function RenderTreeController() {
duration,
canvasSize: { width, height },
background: activeProject.settings.background,
isPreview: true,
});
editor.renderer.setRenderTree({ renderTree });

View File

@ -99,9 +99,9 @@ export function TextEditOverlay({
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = element.letterSpacing ?? 0;
const backgroundColor =
element.backgroundColor === "transparent"
element.background.color === "transparent"
? "transparent"
: element.backgroundColor;
: element.background.color;
return (
<div

View File

@ -0,0 +1,103 @@
"use client";
import type { EffectElement } from "@/types/timeline";
import type { EffectParamDefinition } from "@/types/effects";
import { getEffect } from "@/lib/effects/registry";
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section";
import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field";
import { usePropertyDraft } from "./hooks/use-property-draft";
function EffectParamField({
param,
element,
trackId,
}: {
param: EffectParamDefinition;
element: EffectElement;
trackId: string;
}) {
const editor = useEditor();
const currentValue = Number(element.params[param.key] ?? param.default);
const min = param.min ?? 0;
const max = param.max ?? 100;
const step = param.step ?? 1;
const updateParam = (value: number) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { params: { ...element.params, [param.key]: value } },
},
],
});
const commitParam = () => editor.timeline.commitPreview();
const draft = usePropertyDraft({
displayValue: String(currentValue),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min, max });
},
onPreview: updateParam,
onCommit: commitParam,
});
return (
<SectionField label={param.label}>
<div className="flex items-center gap-3">
<Slider
className="flex-1"
min={min}
max={max}
step={step}
value={[currentValue]}
onValueChange={([value]) => updateParam(value)}
onValueCommit={commitParam}
/>
<NumberField
className="w-16 shrink-0"
value={draft.displayValue}
onFocus={draft.onFocus}
onChange={draft.onChange}
onBlur={draft.onBlur}
/>
</div>
</SectionField>
);
}
export function EffectProperties({
element,
trackId,
}: {
element: EffectElement;
trackId: string;
}) {
const definition = getEffect({ effectType: element.effectType });
return (
<Section hasBorderTop={false}>
<SectionHeader title={definition.name} />
<SectionContent>
<SectionFields>
{definition.params.map((param) => (
<EffectParamField
key={param.key}
param={param}
element={element}
trackId={trackId}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}

View File

@ -0,0 +1,151 @@
import { useEditor } from "@/hooks/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
import type { ElementUpdatePatch } from "@/types/timeline";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
displayValue,
parse,
valueAtPlayhead,
buildBaseUpdates,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch;
}) {
const editor = useEditor();
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
const propertyDraft = usePropertyDraft({
displayValue,
parse,
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: valueAtPlayhead,
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value,
},
],
});
return;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
return {
...propertyDraft,
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
toggleKeyframe,
commitValue,
};
}

View File

@ -4,9 +4,37 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties";
import { EffectProperties } from "./effect-properties";
import { EmptyView } from "./empty-view";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
function ElementProperties({
track,
element,
}: {
track: TimelineTrack;
element: TimelineElement;
}) {
if (element.type === "text") {
return <TextProperties element={element} trackId={track.id} />;
}
if (element.type === "audio") {
return <AudioProperties _element={element} />;
}
if (
element.type === "video" ||
element.type === "image" ||
element.type === "sticker"
) {
return <VideoProperties element={element} trackId={track.id} />;
}
if (element.type === "effect") {
return <EffectProperties element={element} trackId={track.id} />;
}
return null;
}
export function PropertiesPanel() {
const editor = useEditor();
@ -16,30 +44,19 @@ export function PropertiesPanel() {
elements: selectedElements,
});
const hasSelection = selectedElements.length > 0;
return (
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
{selectedElements.length > 0 ? (
<ScrollArea className="h-full">
{elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") {
return (
<div key={element.id}>
<TextProperties element={element} trackId={track.id} />
</div>
);
}
if (element.type === "audio") {
return <AudioProperties key={element.id} _element={element} />;
}
if (element.type === "video" || element.type === "image") {
return (
<div key={element.id}>
<VideoProperties _element={element} />
</div>
);
}
return null;
})}
{hasSelection ? (
<ScrollArea className="h-full scrollbar-hidden">
{elementsWithTracks.map(({ track, element }) => (
<ElementProperties
key={element.id}
track={track}
element={element}
/>
))}
</ScrollArea>
) : (
<EmptyView />

View File

@ -0,0 +1,32 @@
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { KeyframeIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
export function KeyframeToggle({
isActive,
isDisabled = false,
title,
onToggle,
}: {
isActive: boolean;
isDisabled?: boolean;
title: string;
onToggle: () => void;
}) {
return (
<Button
variant="text"
aria-pressed={isActive}
disabled={isDisabled}
title={title}
onClick={onToggle}
className="[&>svg]:size-3.5 mb-0.5"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(isActive && "text-primary fill-primary")}
/>
</Button>
);
}

View File

@ -2,6 +2,7 @@ import { createContext, useContext, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
import { Label } from "@/components/ui/label";
const sectionExpandedCache = new Map<string, boolean>();
@ -131,6 +132,40 @@ export function SectionHeader({
return <div className={baseClassName}>{content}</div>;
}
export function SectionFields({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
);
}
export function SectionField({
label,
beforeLabel,
children,
className,
}: {
label: string;
beforeLabel?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center gap-1.5">
{beforeLabel}
<Label>{label}</Label>
</div>
{children}
</div>
);
}
export function SectionContent({
children,
className,

View File

@ -8,7 +8,7 @@ import {
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionHeader } from "../section";
import { Section, SectionContent, SectionField, SectionHeader } from "../section";
import {
Select,
SelectContent,
@ -124,73 +124,73 @@ export function BlendingSection({
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" />
<SectionContent>
<div className="flex items-start gap-2">
<div className="w-1/2 space-y-1.5">
<NumberField
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Opacity" className="w-1/2">
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: DEFAULT_OPACITY },
},
],
})
}
isDefault={element.opacity === DEFAULT_OPACITY}
dragSensitivity="slow"
/>
</SectionField>
<SectionField label="Blend mode" className="w-1/2">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: DEFAULT_OPACITY },
},
],
})
}
isDefault={element.opacity === DEFAULT_OPACITY}
dragSensitivity="slow"
/>
</div>
<div className="w-1/2 space-y-1.5">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode(option.value as BlendMode)
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</SectionContent>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode(option.value as BlendMode)
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</SectionField>
</div>
</SectionContent>
</Section>
);
}

View File

@ -1,9 +1,15 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import type { ElementType, Transform } from "@/types/timeline";
import { Section, SectionContent, SectionHeader } from "../section";
import { clamp, isNearlyEqual } from "@/utils/math";
import type { AnimationPropertyPath } from "@/types/animation";
import type { VisualElement } from "@/types/timeline";
import {
Section,
SectionContent,
SectionField,
SectionFields,
SectionHeader,
} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@ -13,71 +19,125 @@ import {
} from "@hugeicons/core-free-icons";
import { useState } from "react";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
import { KeyframeToggle } from "../keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
type TransformElement = {
id: string;
transform: Transform;
type: ElementType;
};
function parseFloat_({ input }: { input: string }): number | null {
function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
function isPropertyAtDefault({
hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue,
staticValue,
defaultValue,
}: {
hasAnimatedKeyframes: boolean;
isPlayheadWithinElementRange: boolean;
resolvedValue: number;
staticValue: number;
defaultValue: number;
}): boolean {
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
return isNearlyEqual({
leftValue: resolvedValue,
rightValue: defaultValue,
});
}
return staticValue === defaultValue;
}
export function TransformSection({
element,
trackId,
}: {
element: TransformElement;
element: VisualElement;
trackId: string;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
const playheadTime = editor.playback.getCurrentTime();
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const isPlayheadWithinElementRange =
playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
const previewTransform = (transform: Partial<Transform>) => {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { transform: { ...element.transform, ...transform } },
const positionX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position.x",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.x).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.x,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: {
...element.transform.position,
x: value,
},
],
});
};
const commit = () => editor.timeline.commitPreview();
const positionX = usePropertyDraft({
displayValue: Math.round(element.transform.position.x).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, x: value },
}),
onCommit: commit,
},
}),
});
const positionY = usePropertyDraft({
displayValue: Math.round(element.transform.position.y).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, y: value },
}),
onCommit: commit,
const positionY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position.y",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.y).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.y,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: {
...element.transform.position,
y: value,
},
},
}),
});
const scale = usePropertyDraft({
displayValue: Math.round(element.transform.scale * 100).toString(),
const scale = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scale",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scale * 100).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
onPreview: (value) => previewTransform({ scale: value }),
onCommit: commit,
valueAtPlayhead: resolvedTransform.scale,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scale: value,
},
}),
});
const scaleFieldProps = {
className: "flex-1",
@ -88,160 +148,227 @@ export function TransformSection({
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
onReset: () =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
scale: DEFAULT_TRANSFORM.scale,
},
},
},
],
}),
isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
isDefault: isPropertyAtDefault({
hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.scale,
staticValue: element.transform.scale,
defaultValue: DEFAULT_TRANSFORM.scale,
}),
};
const rotation = usePropertyDraft({
displayValue: Math.round(element.transform.rotate).toString(),
const rotation = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.rotate",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
onPreview: (value) => previewTransform({ rotate: value }),
onCommit: commit,
valueAtPlayhead: resolvedTransform.rotate,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
rotate: value,
},
}),
});
const hasPositionKeyframe =
positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
const togglePositionKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
const keyframesToRemove: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}> = [];
if (positionX.keyframeIdAtTime) {
keyframesToRemove.push({
trackId,
elementId: element.id,
propertyPath: "transform.position.x" as const,
keyframeId: positionX.keyframeIdAtTime,
});
}
if (positionY.keyframeIdAtTime) {
keyframesToRemove.push({
trackId,
elementId: element.id,
propertyPath: "transform.position.y" as const,
keyframeId: positionY.keyframeIdAtTime,
});
}
editor.timeline.removeKeyframes({
keyframes: keyframesToRemove,
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId: element.id,
propertyPath: "transform.position.x",
time: localTime,
value: resolvedTransform.position.x,
},
{
trackId,
elementId: element.id,
propertyPath: "transform.position.y",
time: localTime,
value: resolvedTransform.position.y,
},
],
});
};
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
<SectionContent>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
<NumberField icon="W" {...scaleFieldProps} />
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
<SectionFields>
<SectionField
label="Scale"
beforeLabel={
<KeyframeToggle
isActive={scale.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle scale keyframe"
onToggle={scale.toggleKeyframe}
/>
)}
<Button
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
x: DEFAULT_TRANSFORM.position.x,
},
},
},
},
],
})
}
isDefault={
element.transform.position.x === DEFAULT_TRANSFORM.position.x
}
/>
<NumberField
icon="Y"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
y: DEFAULT_TRANSFORM.position.y,
},
},
},
},
],
})
}
isDefault={
element.transform.position.y === DEFAULT_TRANSFORM.position.y
}
/>
</div>
}
>
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
<NumberField icon="W" {...scaleFieldProps} />
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
/>
)}
<Button
type="button"
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
</SectionField>
<SectionField
label="Position"
beforeLabel={
<KeyframeToggle
isActive={hasPositionKeyframe}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle position keyframe"
onToggle={togglePositionKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.x,
staticValue: element.transform.position.x,
defaultValue: DEFAULT_TRANSFORM.position.x,
})}
/>
<NumberField
icon="Y"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.y,
staticValue: element.transform.position.y,
defaultValue: DEFAULT_TRANSFORM.position.y,
})}
/>
</div>
</SectionField>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-1"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
rotate: DEFAULT_TRANSFORM.rotate,
},
},
},
],
})
}
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
/>
</div>
</div>
<SectionField
label="Rotation"
beforeLabel={
<KeyframeToggle
isActive={rotation.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle rotation keyframe"
onToggle={rotation.toggleKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-none"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.rotate,
staticValue: element.transform.rotate,
defaultValue: DEFAULT_TRANSFORM.rotate,
})}
/>
</div>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);

View File

@ -1,31 +1,49 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import { Section, SectionContent, SectionHeader } from "./section";
import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { OcFontWeightIcon } from "@opencut/ui/icons";
import { Label } from "@/components/ui/label";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextFontIcon } from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
function createOffsetConverter({
defaultValue,
scale = 1,
min,
}: {
defaultValue: number;
scale?: number;
min?: number;
}) {
return {
toDisplay: (value: number) => Math.round((value - defaultValue) * scale),
fromDisplay: (display: number) => {
const stored = defaultValue + display / scale;
return min !== undefined ? Math.max(min, stored) : stored;
},
};
}
const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 });
const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 });
const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 });
export function TextProperties({
element,
@ -39,8 +57,9 @@ export function TextProperties({
<ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
<FontSection element={element} trackId={trackId} />
<ColorSection element={element} trackId={trackId} />
<TypographySection element={element} trackId={trackId} />
<SpacingSection element={element} trackId={trackId} />
<BackgroundSection element={element} trackId={trackId} />
</div>
);
}
@ -83,7 +102,7 @@ function ContentSection({
);
}
function FontSection({
function TypographySection({
element,
trackId,
}: {
@ -109,10 +128,11 @@ function FontSection({
});
return (
<Section collapsible sectionKey="text:font">
<SectionHeader title="Font" />
<SectionContent>
<div className="flex flex-col gap-2">
<Section collapsible sectionKey="text:typography">
<SectionHeader title="Typography" />
<SectionContent>
<SectionFields>
<SectionField label="Font">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
@ -127,89 +147,33 @@ function FontSection({
})
}
/>
<Select value={element.fontWeight}>
<SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
<SelectValue placeholder="Select weight" />
</SelectTrigger>
<SelectContent>
<SelectItem value="100">Thin</SelectItem>
<SelectItem value="200">Extra Light</SelectItem>
<SelectItem value="300">Light</SelectItem>
<SelectItem value="400">Normal</SelectItem>
<SelectItem value="500">Medium</SelectItem>
<SelectItem value="600">Semi Bold</SelectItem>
<SelectItem value="700">Bold</SelectItem>
<SelectItem value="800">Extra Bold</SelectItem>
<SelectItem value="900">Black</SelectItem>
</SelectContent>
</Select>
<Label>Font size</Label>
<div className="flex items-center gap-2">
<Slider
value={[element.fontSize]}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
onValueChange={([value]) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: value },
},
],
})
}
onValueCommit={() => editor.timeline.commitPreview()}
className="w-full"
/>
<NumberField
className="w-18 shrink-0"
value={fontSize.displayValue}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={fontSize.onFocus}
onChange={fontSize.onChange}
onBlur={fontSize.onBlur}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
},
},
],
})
}
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
/>
</div>
</div>
</SectionContent>
</Section>
);
}
function ColorSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
return (
<Section collapsible sectionKey="text:color" hasBorderBottom={false}>
<SectionHeader title="Color" />
<SectionContent>
<div className="flex flex-col gap-6">
</SectionField>
<SectionField label="Size">
<NumberField
value={fontSize.displayValue}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={fontSize.onFocus}
onChange={fontSize.onChange}
onBlur={fontSize.onBlur}
onScrub={fontSize.scrubTo}
onScrubEnd={fontSize.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
},
],
})
}
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
<SectionField label="Color">
<ColorPicker
value={uppercase({
string: (element.color || "FFFFFF").replace("#", ""),
@ -227,35 +191,385 @@ function ColorSection({
}
onChangeEnd={() => editor.timeline.commitPreview()}
/>
<ColorPicker
value={
element.backgroundColor === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.backgroundColor.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { backgroundColor: hexColor },
},
],
});
}}
onChangeEnd={() => editor.timeline.commitPreview()}
className={
element.backgroundColor === "transparent"
? "pointer-events-none opacity-50"
: ""
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}
function SpacingSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const letterSpacing = usePropertyDraft({
displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const lineHeight = usePropertyDraft({
displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:spacing" hasBorderBottom={false}>
<SectionHeader title="Spacing" />
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Letter spacing" className="w-1/2">
<NumberField
value={letterSpacing.displayValue}
onFocus={letterSpacing.onFocus}
onChange={letterSpacing.onChange}
onBlur={letterSpacing.onBlur}
onScrub={letterSpacing.scrubTo}
onScrubEnd={letterSpacing.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }],
})
}
isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING}
icon={<OcTextWidthIcon size={14} />}
/>
</div>
</SectionField>
<SectionField label="Line height" className="w-1/2">
<NumberField
value={lineHeight.displayValue}
onFocus={lineHeight.onFocus}
onChange={lineHeight.onChange}
onBlur={lineHeight.onBlur}
onScrub={lineHeight.scrubTo}
onScrubEnd={lineHeight.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }],
})
}
isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT}
icon={<OcTextHeightIcon size={14} />}
/>
</SectionField>
</div>
</SectionContent>
</Section>
);
}
function BackgroundSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
const cornerRadius = usePropertyDraft({
displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, cornerRadius: value },
},
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const paddingX = usePropertyDraft({
displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, paddingX: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const paddingY = usePropertyDraft({
displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, paddingY: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const offsetX = usePropertyDraft({
displayValue: Math.round(element.background.offsetX ?? 0).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, offsetX: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const offsetY = usePropertyDraft({
displayValue: Math.round(element.background.offsetY ?? 0).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, offsetY: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:background">
<SectionHeader title="Background" />
<SectionContent>
<SectionFields>
<SectionField label="Color">
<ColorPicker
value={
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.background.color.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, color: hexColor },
},
},
],
});
}}
onChangeEnd={() => editor.timeline.commitPreview()}
className={
element.background.color === "transparent"
? "pointer-events-none opacity-50"
: ""
}
/>
</SectionField>
<div className="flex items-start gap-2">
<SectionField label="Width" className="w-1/2">
<NumberField
icon="W"
value={paddingX.displayValue}
min={0}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
paddingX: DEFAULT_TEXT_BACKGROUND.paddingX,
},
},
},
],
})
}
isDefault={
(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
DEFAULT_TEXT_BACKGROUND.paddingX
}
/>
</SectionField>
<SectionField label="Height" className="w-1/2">
<NumberField
icon="H"
value={paddingY.displayValue}
min={0}
onFocus={paddingY.onFocus}
onChange={paddingY.onChange}
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
paddingY: DEFAULT_TEXT_BACKGROUND.paddingY,
},
},
},
],
})
}
isDefault={
(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
DEFAULT_TEXT_BACKGROUND.paddingY
}
/>
</SectionField>
</div>
<div className="flex items-start gap-2">
<SectionField label="X-offset" className="w-1/2">
<NumberField
icon="X"
value={offsetX.displayValue}
onFocus={offsetX.onFocus}
onChange={offsetX.onChange}
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, offsetX: 0 },
},
},
],
})
}
isDefault={(element.background.offsetX ?? 0) === 0}
/>
</SectionField>
<SectionField label="Y-offset" className="w-1/2">
<NumberField
icon="Y"
value={offsetY.displayValue}
onFocus={offsetY.onFocus}
onChange={offsetY.onChange}
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, offsetY: 0 },
},
},
],
})
}
isDefault={(element.background.offsetY ?? 0) === 0}
/>
</SectionField>
</div>
<SectionField label="Corner Radius">
<NumberField
icon="R"
value={cornerRadius.displayValue}
min={0}
onFocus={cornerRadius.onFocus}
onChange={cornerRadius.onChange}
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
cornerRadius: 0,
},
},
},
],
})
}
isDefault={(element.background.cornerRadius ?? 0) === 0}
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);

View File

@ -1,9 +1,17 @@
import type { ImageElement, VideoElement } from "@/types/timeline";
import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline";
import { BlendingSection, TransformSection } from "./sections";
export function VideoProperties({
_element,
element,
trackId,
}: {
_element: VideoElement | ImageElement;
element: VideoElement | ImageElement | StickerElement;
trackId: string;
}) {
return <div className="space-y-4 p-5">Video properties</div>;
return (
<div className="flex h-full flex-col">
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
</div>
);
}

View File

@ -5,10 +5,7 @@ import type { EditorCore } from "@/core";
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_CONSTANTS,
} from "@/constants/timeline-constants";
import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
@ -28,8 +25,8 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline";
const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
const BOOKMARK_MARKER_HEIGHT_PX = 15;
@ -139,10 +136,12 @@ function TimelineBookmark({
const time = bookmark.time;
const bookmarkDuration = bookmark.duration ?? 0;
const durationWidth =
bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
bookmarkDuration > 0
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
: 0;
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
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;

View File

@ -23,11 +23,11 @@ import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "../../selection-box";
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
TRACK_ICONS,
TRACK_CONFIG,
} from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@ -161,6 +161,7 @@ export function Timeline() {
shouldIgnoreClick,
} = useSelectionBox({
containerRef: tracksContainerRef,
headerRef: timelineHeaderRef,
onSelectionComplete: (elements) => {
setElementSelection({ elements });
},
@ -326,7 +327,7 @@ export function Timeline() {
<DragLine
dropTarget={dropTarget}
tracks={timeline.getTracks()}
isVisible={isDragOver}
isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight}
/>
<DragLine
@ -453,6 +454,11 @@ export function Timeline() {
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
targetElementId={
isDragOver
? dropTarget?.targetElement?.elementId ?? null
: null
}
/>
</div>
</ContextMenuTrigger>
@ -523,7 +529,7 @@ export function Timeline() {
}
function TrackIcon({ track }: { track: TimelineTrack }) {
return <>{TRACK_ICONS[track.type]}</>;
return <>{TRACK_CONFIG[track.type].icon}</>;
}
function TrackToggleIcon({

View File

@ -1,7 +1,11 @@
"use client";
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
interface SnapIndicatorProps {
@ -40,10 +44,10 @@ export function SnapIndicator({
<div
className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
top: topPosition,
height: `${height}px`,
width: "2px",
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
}}
>
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />

View File

@ -4,14 +4,17 @@ import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
getTrackClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
hasMediaId,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import {
ContextMenu,
@ -19,7 +22,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../../../ui/context-menu";
} from "@/components/ui/context-menu";
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
@ -42,10 +45,105 @@ import {
VolumeMute02Icon,
Search01Icon,
Exchange01Icon,
KeyframeIcon,
MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
import { cn } from "@/utils/ui";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
interface KeyframeIndicator {
time: number;
offsetPx: number;
keyframes: SelectedKeyframeRef[];
}
function buildKeyframeIndicator({
keyframe,
trackId,
elementId,
displayedStartTime,
zoomLevel,
elementLeft,
}: {
keyframe: ElementKeyframe;
trackId: string;
elementId: string;
displayedStartTime: number;
zoomLevel: number;
elementLeft: number;
}): {
time: number;
offsetPx: number;
keyframeRef: SelectedKeyframeRef;
} {
const keyframeRef = {
trackId,
elementId,
propertyPath: keyframe.propertyPath,
keyframeId: keyframe.id,
};
const keyframeLeft = timelineTimeToSnappedPixels({
time: displayedStartTime + keyframe.time,
zoomLevel,
});
return {
time: keyframe.time,
offsetPx: keyframeLeft - elementLeft,
keyframeRef,
};
}
function getKeyframeIndicators({
keyframes,
trackId,
elementId,
displayedStartTime,
zoomLevel,
elementLeft,
elementWidth,
}: {
keyframes: ElementKeyframe[];
trackId: string;
elementId: string;
displayedStartTime: number;
zoomLevel: number;
elementLeft: number;
elementWidth: number;
}): KeyframeIndicator[] {
if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
return [];
}
const keyframesByTime = new Map<number, KeyframeIndicator>();
for (const keyframe of keyframes) {
const indicator = buildKeyframeIndicator({
keyframe,
trackId,
elementId,
displayedStartTime,
zoomLevel,
elementLeft,
});
const existingIndicator = keyframesByTime.get(indicator.time);
if (!existingIndicator) {
keyframesByTime.set(indicator.time, {
time: indicator.time,
offsetPx: indicator.offsetPx,
keyframes: [indicator.keyframeRef],
});
continue;
}
existingIndicator.keyframes.push(indicator.keyframeRef);
}
return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
}
function getDisplayShortcut(action: TAction) {
const { defaultShortcuts } = getActionDefinition(action);
@ -71,6 +169,7 @@ interface TimelineElementProps {
) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
isDropTarget?: boolean;
}
export function TimelineElement({
@ -83,6 +182,7 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
dragState,
isDropTarget = false,
}: TimelineElementProps) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
@ -123,10 +223,25 @@ export function TimelineElement({
: element.startTime;
const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
const displayedDuration = isResizing ? currentDuration : element.duration;
const elementWidth =
displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const elementLeft = displayedStartTime * 50 * zoomLevel;
const elementWidth = timelineTimeToPixels({
time: displayedDuration,
zoomLevel,
});
const elementLeft = timelineTimeToSnappedPixels({
time: displayedStartTime,
zoomLevel,
});
const keyframeIndicators = isSelected
? getKeyframeIndicators({
keyframes: getElementKeyframes({ animations: element.animations }),
trackId: track.id,
elementId: element.id,
displayedStartTime,
zoomLevel,
elementLeft,
elementWidth,
})
: [];
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@ -160,7 +275,9 @@ export function TimelineElement({
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
isDropTarget={isDropTarget}
/>
{isSelected && <KeyframeIndicators indicators={keyframeIndicators} />}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
@ -197,7 +314,9 @@ export function TimelineElement({
<>
<ContextMenuItem
icon={<HugeiconsIcon icon={Search01Icon} />}
onClick={(event) => handleRevealInMedia({ event })}
onClick={(event: React.MouseEvent) =>
handleRevealInMedia({ event })
}
>
Reveal media
</ContextMenuItem>
@ -231,6 +350,7 @@ function ElementInner({
onElementClick,
onElementMouseDown,
handleResizeStart,
isDropTarget = false,
}: {
element: TimelineElementType;
track: TimelineTrack;
@ -248,14 +368,20 @@ function ElementInner({
elementId: string;
side: "left" | "right";
}) => void;
isDropTarget?: boolean;
}) {
const opacityClass =
(canElementBeHidden(element) && element.hidden) || isDropTarget
? "opacity-50"
: "";
return (
<div
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
{
type: track.type,
},
)} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
)} ${opacityClass}`}
>
<button
type="button"
@ -271,7 +397,6 @@ function ElementInner({
mediaAssets={mediaAssets}
/>
</div>
{(hasAudio
? isMuted
: canElementBeHidden(element) && element.hidden) && (
@ -335,52 +460,142 @@ function ResizeHandle({
);
}
function ElementContent({
element,
track,
isSelected,
mediaAssets,
function KeyframeIndicators({
indicators,
}: {
indicators: KeyframeIndicator[];
}) {
const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } =
useKeyframeSelection();
const orderedKeyframes = indicators.flatMap(
(indicator) => indicator.keyframes,
);
const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => {
event.preventDefault();
event.stopPropagation();
};
const handleKeyframeClick = ({
event,
keyframes,
}: {
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
}) => {
event.stopPropagation();
if (event.shiftKey) {
selectKeyframeRange({
orderedKeyframes,
targetKeyframes: keyframes,
isAdditive: event.metaKey || event.ctrlKey,
});
return;
}
toggleKeyframeSelection({
keyframes,
isMultiKey: event.metaKey || event.ctrlKey,
});
};
return indicators.map((indicator) => {
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
return (
<button
key={indicator.time}
type="button"
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer"
style={{ left: indicator.offsetPx }}
onMouseDown={(event) => handleKeyframeMouseDown({ event })}
onClick={(event) =>
handleKeyframeClick({ event, keyframes: indicator.keyframes })
}
aria-label="Select keyframe"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black",
isIndicatorSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
/>
</button>
);
});
}
interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
mediaAssets: MediaAsset[];
}) {
if (element.type === "text") {
}
type ElementContentRenderer = (props: ElementContentProps) => ReactNode;
const ELEMENT_CONTENT_RENDERERS: Record<
TimelineElementType["type"],
ElementContentRenderer
> = {
text: ({ element }) => {
const textElement = element as Extract<
TimelineElementType,
{ type: "text" }
>;
return (
<div className="flex size-full items-center justify-start pl-2">
<span className="truncate text-xs text-white">{element.content}</span>
<div className="flex size-full items-center justify-start pl-3">
<span className="truncate text-xs text-white">
{textElement.content}
</span>
</div>
);
}
if (element.type === "sticker") {
},
effect: ({ element }) => (
<div className="flex size-full items-center justify-start gap-1 pl-2">
<HugeiconsIcon icon={MagicWand05Icon} className="size-4 shrink-0 text-white" />
<span className="truncate text-xs text-white">{element.name}</span>
</div>
),
sticker: ({ element }) => {
const stickerElement = element as Extract<
TimelineElementType,
{ type: "sticker" }
>;
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
src={resolveStickerId({
stickerId: element.stickerId,
stickerId: stickerElement.stickerId,
options: { width: 20, height: 20 },
})}
alt={element.name}
alt={stickerElement.name}
className="size-5 shrink-0"
width={20}
height={20}
unoptimized
/>
<span className="truncate text-xs text-white">{element.name}</span>
<span className="truncate text-xs text-white">
{stickerElement.name}
</span>
</div>
);
}
if (element.type === "audio") {
},
audio: ({ element, mediaAssets }) => {
const audioElement = element as Extract<
TimelineElementType,
{ type: "audio" }
>;
const audioBuffer =
element.sourceType === "library" ? element.buffer : undefined;
audioElement.sourceType === "library" ? audioElement.buffer : undefined;
const audioUrl =
element.sourceType === "library"
? element.sourceUrl
: mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
audioElement.sourceType === "library"
? audioElement.sourceUrl
: mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url;
if (audioBuffer || audioUrl) {
return (
@ -399,28 +614,78 @@ function ElementContent({
return (
<span className="text-foreground/80 truncate text-xs">
{element.name}
{audioElement.name}
</span>
);
}
},
video: ({ element, track, isSelected, mediaAssets }) => {
const videoElement = element as Extract<
TimelineElementType,
{ type: "video" }
>;
const mediaAsset = mediaAssets.find(
(asset) => asset.id === videoElement.mediaId,
);
if (!mediaAsset) {
return (
<span className="text-foreground/80 truncate text-xs">
{videoElement.name}
</span>
);
}
if (mediaAsset.thumbnailUrl) {
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * (16 / 9);
return (
<div className="flex size-full items-center justify-center">
<div
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
>
<div
className="absolute right-0 left-0"
style={{
backgroundImage: `url(${mediaAsset.thumbnailUrl})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
top: isSelected ? "0.25rem" : "0rem",
bottom: isSelected ? "0.25rem" : "0rem",
}}
/>
</div>
</div>
);
}
const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
if (!mediaAsset) {
return (
<span className="text-foreground/80 truncate text-xs">
{element.name}
{videoElement.name}
</span>
);
}
},
image: ({ element, track, isSelected, mediaAssets }) => {
const imageElement = element as Extract<
TimelineElementType,
{ type: "image" }
>;
const mediaAsset = mediaAssets.find(
(asset) => asset.id === imageElement.mediaId,
);
if (!mediaAsset?.url) {
return (
<span className="text-foreground/80 truncate text-xs">
{imageElement.name}
</span>
);
}
if (
mediaAsset.type === "image" ||
(mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
) {
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * (16 / 9);
const imageUrl =
mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
return (
<div className="flex size-full items-center justify-center">
@ -430,7 +695,7 @@ function ElementContent({
<div
className="absolute right-0 left-0"
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundImage: `url(${mediaAsset.url})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
@ -442,11 +707,12 @@ function ElementContent({
</div>
</div>
);
}
},
};
return (
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
);
function ElementContent(props: ElementContentProps) {
const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type];
return <>{renderer(props)}</>;
}
function CopyMenuItem() {
@ -549,10 +815,11 @@ function ActionMenuItem({
...props
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
action: TAction;
children: ReactNode;
}) {
return (
<ContextMenuItem
onClick={(event) => {
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction(action);
}}

View File

@ -1,7 +1,11 @@
"use client";
import { useRef } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
@ -43,9 +47,11 @@ export function TimelinePlayhead({
400;
const totalHeight = Math.max(0, timelineContainerHeight - 4);
const timelinePosition =
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const leftPosition = timelinePosition;
const centerPosition = timelineTimeToSnappedPixels({
time: playheadPosition,
zoomLevel,
});
const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
const handlePlayheadKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>,
@ -77,7 +83,7 @@ export function TimelinePlayhead({
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px",
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
}}
onKeyDown={handlePlayheadKeyDown}
>

View File

@ -1,6 +1,6 @@
"use client";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
interface TimelineTickProps {
@ -16,7 +16,7 @@ export function TimelineTick({
fps,
showLabel,
}: TimelineTickProps) {
const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
if (showLabel) {
const label = formatRulerLabel({ timeInSeconds: time, fps });

View File

@ -4,7 +4,7 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select
import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/types/timeline";
@ -32,6 +32,7 @@ interface TimelineTrackContentProps {
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
targetElementId?: string | null;
}
export function TimelineTrackContent({
@ -48,6 +49,7 @@ export function TimelineTrackContent({
onTrackMouseDown,
onTrackClick,
shouldIgnoreClick,
targetElementId = null,
}: TimelineTrackContentProps) {
const editor = useEditor();
const { isElementSelected, clearElementSelection } = useElementSelection();
@ -102,6 +104,7 @@ export function TimelineTrackContent({
onElementClick({ event, element, track })
}
dragState={dragState}
isDropTarget={element.id === targetElementId}
/>
);
})

View File

@ -14,7 +14,7 @@ export function Hero() {
src="/landing-page-dark.png"
height={1903.5}
width={1269}
alt="landing-page.bg"
alt="OpenCut video editor landing page background"
/>
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center">
<div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]">

View File

@ -280,13 +280,21 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
)}
{...props}
>
<PopoverTrigger asChild>
<button
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20"
style={{ backgroundColor: `#${value}` }}
type="button"
<PopoverTrigger asChild>
<button
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
type="button"
>
<span
className="absolute inset-0"
style={checkerboardStyle}
/>
</PopoverTrigger>
<span
className="absolute inset-0"
style={{ backgroundColor: `#${value}` }}
/>
</button>
</PopoverTrigger>
<div className="flex flex-1 items-center">
<Input
className={cn(

View File

@ -26,7 +26,7 @@ import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
import { cn } from "@/utils/ui";
import { ChevronDown, Search, Upload } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextFontIcon } from "@hugeicons/core-free-icons";
import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
@ -139,7 +139,7 @@ export function FontPicker({
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
<HugeiconsIcon icon={TextFontIcon} />
<HugeiconsIcon icon={TextIcon} />
</span>
<span className="truncate" style={{ fontFamily: defaultValue }}>
{defaultValue ?? "Select a font"}

View File

@ -71,9 +71,12 @@ function NumberField({
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
const sensitivity =
typeof dragSensitivity === "number"
? dragSensitivity
: DRAG_SENSITIVITIES[dragSensitivity];
const newValue =
startValueRef.current +
cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
startValueRef.current + cumulativeDeltaRef.current * sensitivity;
onScrub(newValue);
};

View File

@ -40,7 +40,7 @@ const selectTriggerVariants = cva(
},
size: {
default: "",
sm: "",
sm: "rounded-sm",
},
},
defaultVariants: {

View File

@ -7,7 +7,9 @@ import { cn } from "@/utils/ui";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}

View File

@ -0,0 +1,2 @@
export const TIME_EPSILON_SECONDS = 1 / 1000;
export const MIN_TRANSFORM_SCALE = 0.01;

View File

@ -17,6 +17,15 @@ export const FONT_SIZE_SCALE_REFERENCE = 90;
export const DEFAULT_LETTER_SPACING = 0;
export const DEFAULT_LINE_HEIGHT = 1.2;
export const DEFAULT_TEXT_BACKGROUND = {
color: "#000000",
cornerRadius: 0,
paddingX: 30,
paddingY: 42,
offsetX: 0,
offsetY: 0,
};
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
@ -24,7 +33,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
fontSize: 15,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "#000000",
background: DEFAULT_TEXT_BACKGROUND,
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",

View File

@ -3,6 +3,7 @@ import type { BlendMode } from "@/types/rendering";
import type { TrackType, Transform } from "@/types/timeline";
import {
Happy01Icon,
MagicWand05Icon,
MusicNote03Icon,
TextIcon,
} from "@hugeicons/core-free-icons";
@ -19,26 +20,65 @@ export const DEFAULT_OPACITY = 1;
export const DEFAULT_BLEND_MODE: BlendMode = "normal";
export const DEFAULT_BOOKMARK_COLOR = "#009dff";
export const TRACK_COLORS: Record<TrackType, { background: string }> = {
export const TRACK_CONFIG: Record<
TrackType,
{
background: string;
height: number;
defaultName: string;
icon: React.ReactNode;
}
> = {
video: {
background: "transparent",
height: 60,
defaultName: "Video track",
icon: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
},
text: {
background: "bg-[#5DBAA0]",
height: 25,
defaultName: "Text track",
icon: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
audio: {
background: "bg-[#915DBE]",
background: "bg-[#8F5DBA]",
height: 50,
defaultName: "Audio track",
icon: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
sticker: {
background: "bg-amber-500",
background: "bg-[#BA5D7A]",
height: 50,
defaultName: "Sticker track",
icon: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
effect: {
background: "bg-[#5d93ba]",
height: 25,
defaultName: "Effect track",
icon: (
<HugeiconsIcon
icon={MagicWand05Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
} as const;
export const TRACK_HEIGHTS: Record<TrackType, number> = {
video: 60,
text: 25,
audio: 50,
sticker: 50,
} as const;
export const TRACK_GAP = 4;
@ -60,25 +100,3 @@ export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
scrollLeft: 0,
playheadTime: 0,
};
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
text: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
audio: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
sticker: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
} as const;

View File

@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
export class EditorCore {
private static instance: EditorCore | null = null;
@ -24,6 +25,7 @@ export class EditorCore {
public readonly selection: SelectionManager;
private constructor() {
registerDefaultEffects();
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);

View File

@ -29,6 +29,7 @@ export class AudioManager {
private playbackSessionId = 0;
private lastIsPlaying = false;
private lastVolume = 1;
private playbackLatencyCompensationSeconds = 0;
private unsubscribers: Array<() => void> = [];
constructor(private editor: EditorCore) {
@ -136,6 +137,7 @@ export class AudioManager {
this.stopPlayback();
this.playbackSessionId++;
this.playbackLatencyCompensationSeconds = 0;
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
@ -224,13 +226,21 @@ export class AudioManager {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + clip.duration;
const iteratorStartTime = Math.max(startTime, clipStart);
const playbackTimeAfterSinkReady = this.getPlaybackTime();
const iteratorStartTime = Math.max(
startTime,
clipStart,
playbackTimeAfterSinkReady,
);
if (iteratorStartTime >= clipEnd) {
return;
}
const sourceStartTime =
clip.trimStart + (iteratorStartTime - clip.startTime);
const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
let consecutiveDroppedBufferCount = 0;
for await (const { buffer, timestamp } of iterator) {
if (!this.editor.playback.getIsPlaying()) return;
@ -244,15 +254,41 @@ export class AudioManager {
node.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
this.playbackStartContextTime + (timelineTime - this.playbackStartTime);
this.playbackStartContextTime +
this.playbackLatencyCompensationSeconds +
(timelineTime - this.playbackStartTime);
if (startTimestamp >= audioContext.currentTime) {
node.start(startTimestamp);
consecutiveDroppedBufferCount = 0;
} else {
const offset = audioContext.currentTime - startTimestamp;
if (offset < buffer.duration) {
node.start(audioContext.currentTime, offset);
consecutiveDroppedBufferCount = 0;
} else {
consecutiveDroppedBufferCount += 1;
if (consecutiveDroppedBufferCount >= 5) {
const nextCompensationSeconds = Math.max(
this.playbackLatencyCompensationSeconds,
Math.min(0.25, offset + 0.01),
);
if (
nextCompensationSeconds >
this.playbackLatencyCompensationSeconds + 0.001
) {
this.playbackLatencyCompensationSeconds =
nextCompensationSeconds;
}
const resyncStartTime = this.getPlaybackTime();
this.clipIterators.delete(clip.id);
void this.runClipIterator({
clip,
startTime: resyncStartTime,
sessionId,
});
return;
}
continue;
}
}

View File

@ -1,9 +1,12 @@
import type { EditorCore } from "@/core";
import type { SelectedKeyframeRef } from "@/types/animation";
type ElementRef = { trackId: string; elementId: string };
export class SelectionManager {
private selectedElements: ElementRef[] = [];
private selectedKeyframes: SelectedKeyframeRef[] = [];
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
private listeners = new Set<() => void>();
constructor(editor: EditorCore) {
@ -14,13 +17,47 @@ export class SelectionManager {
return this.selectedElements;
}
getSelectedKeyframes(): SelectedKeyframeRef[] {
return this.selectedKeyframes;
}
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
return this.keyframeSelectionAnchor;
}
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
this.selectedElements = elements;
this.selectedKeyframes = [];
this.keyframeSelectionAnchor = null;
this.notify();
}
setSelectedKeyframes({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef | null;
}): void {
this.selectedKeyframes = keyframes;
if (anchorKeyframe !== undefined) {
this.keyframeSelectionAnchor = anchorKeyframe;
} else if (keyframes.length === 0) {
this.keyframeSelectionAnchor = null;
}
this.notify();
}
clearSelection(): void {
this.selectedElements = [];
this.selectedKeyframes = [];
this.keyframeSelectionAnchor = null;
this.notify();
}
clearKeyframeSelection(): void {
this.selectedKeyframes = [];
this.keyframeSelectionAnchor = null;
this.notify();
}

View File

@ -5,6 +5,11 @@ import type {
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
} from "@/types/animation";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
@ -24,6 +29,9 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
} from "@/lib/commands/timeline";
import { BatchCommand, PreviewTracker } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
@ -61,7 +69,11 @@ export class TimelineManager {
trimEnd: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd);
const command = new UpdateElementTrimCommand({
elementId,
trimStart,
trimEnd,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@ -80,11 +92,11 @@ export class TimelineManager {
duration: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementDurationCommand(
const command = new UpdateElementDurationCommand({
trackId,
elementId,
duration,
);
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@ -99,7 +111,10 @@ export class TimelineManager {
elements: { trackId: string; elementId: string }[];
startTime: number;
}): void {
const command = new UpdateElementStartTimeCommand(elements, startTime);
const command = new UpdateElementStartTimeCommand({
elements,
startTime,
});
this.editor.command.execute({ command });
}
@ -116,13 +131,13 @@ export class TimelineManager {
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}): void {
const command = new MoveElementCommand(
const command = new MoveElementCommand({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
);
});
this.editor.command.execute({ command });
}
@ -147,12 +162,12 @@ export class TimelineManager {
retainSide?: "both" | "left" | "right";
rippleEnabled?: boolean;
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand(
const command = new SplitElementsCommand({
elements,
splitTime,
retainSide,
rippleEnabled,
);
});
this.editor.command.execute({ command });
return command.getRightSideElements();
}
@ -206,7 +221,7 @@ export class TimelineManager {
elements: { trackId: string; elementId: string }[];
rippleEnabled?: boolean;
}): void {
const command = new DeleteElementsCommand(elements, rippleEnabled);
const command = new DeleteElementsCommand({ elements, rippleEnabled });
this.editor.command.execute({ command });
}
@ -217,13 +232,17 @@ export class TimelineManager {
updates: Array<{
trackId: string;
elementId: string;
updates: Partial<Record<string, unknown>>;
updates: Partial<TimelineElement>;
}>;
pushHistory?: boolean;
}): void {
const commands = updates.map(
({ trackId, elementId, updates: elementUpdates }) =>
new UpdateElementCommand(trackId, elementId, elementUpdates),
new UpdateElementCommand({
trackId,
elementId,
updates: elementUpdates,
}),
);
const command =
commands.length === 1 ? commands[0] : new BatchCommand(commands);
@ -234,6 +253,99 @@ export class TimelineManager {
}
}
upsertKeyframes({
keyframes,
}: {
keyframes: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}>;
}): void {
if (keyframes.length === 0) {
return;
}
const commands = keyframes.map(
({
trackId,
elementId,
propertyPath,
time,
value,
interpolation,
keyframeId,
}) =>
new UpsertKeyframeCommand({
trackId,
elementId,
propertyPath,
time,
value,
interpolation,
keyframeId,
}),
);
const command =
commands.length === 1 ? commands[0] : new BatchCommand(commands);
this.editor.command.execute({ command });
}
removeKeyframes({
keyframes,
}: {
keyframes: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}>;
}): void {
if (keyframes.length === 0) {
return;
}
const commands = keyframes.map(
({ trackId, elementId, propertyPath, keyframeId }) =>
new RemoveKeyframeCommand({
trackId,
elementId,
propertyPath,
keyframeId,
}),
);
const command =
commands.length === 1 ? commands[0] : new BatchCommand(commands);
this.editor.command.execute({ command });
}
retimeKeyframe({
trackId,
elementId,
propertyPath,
keyframeId,
time,
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
time: number;
}): void {
const command = new RetimeKeyframeCommand({
trackId,
elementId,
propertyPath,
keyframeId,
nextTime: time,
});
this.editor.command.execute({ command });
}
isPreviewActive(): boolean {
return this.previewTracker.isActive();
}
@ -244,7 +356,7 @@ export class TimelineManager {
updates: Array<{
trackId: string;
elementId: string;
updates: Partial<Record<string, unknown>>;
updates: Partial<TimelineElement>;
}>;
}): void {
const tracks = this.getTracks();

View File

@ -17,14 +17,16 @@ import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import {
snapElementEdge,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import type {
DropTarget,
ElementDragState,
TimelineElement,
TimelineTrack,
} from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
interface UseElementInteractionProps {
zoomLevel: number;
@ -162,7 +164,6 @@ export function useElementInteraction({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const { snapElementEdge } = useTimelineSnapping();
const {
isElementSelected,
selectElement,
@ -255,14 +256,7 @@ export function useElementInteraction({
snapPoint: snapResult.snapPoint,
};
},
[
snappingEnabled,
editor.playback,
snapElementEdge,
tracks,
zoomLevel,
isShiftHeldRef,
],
[snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
@ -598,9 +592,12 @@ export function useElementInteraction({
});
if (!alreadySelected) {
selectElement({ trackId: track.id, elementId: element.id });
return;
}
editor.selection.clearKeyframeSelection();
},
[isElementSelected, selectElement],
[editor.selection, isElementSelected, selectElement],
);
return {

View File

@ -5,9 +5,10 @@ import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
useTimelineSnapping,
findSnapPoints,
snapToNearestPoint,
type SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
} from "@/lib/timeline/snap-utils";
import { useTimelineStore } from "@/stores/timeline-store";
export interface ResizeState {
@ -39,7 +40,7 @@ export function useTimelineElementResize({
const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@ -85,12 +86,8 @@ export function useTimelineElementResize({
};
const canExtendElementDuration = useCallback(() => {
if (element.type === "text" || element.type === "image") {
return true;
}
return false;
}, [element.type]);
return element.sourceDuration == null;
}, [element.sourceDuration]);
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
@ -269,8 +266,6 @@ export function useTimelineElementResize({
activeProject.settings.fps,
snappingEnabled,
editor,
findSnapPoints,
snapToNearestPoint,
element.id,
onSnapPointChange,
canExtendElementDuration,

View File

@ -0,0 +1,229 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/types/animation";
function getSelectedKeyframeId({
keyframe,
}: {
keyframe: SelectedKeyframeRef;
}): string {
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
}
function mergeUniqueKeyframes({
keyframes,
}: {
keyframes: SelectedKeyframeRef[];
}): SelectedKeyframeRef[] {
const keyframesById = new Map<string, SelectedKeyframeRef>();
for (const keyframe of keyframes) {
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
}
return [...keyframesById.values()];
}
export function useKeyframeSelection() {
const editor = useEditor();
const selectedKeyframes = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedKeyframes(),
);
const keyframeSelectionAnchor = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getKeyframeSelectionAnchor(),
);
const isKeyframeSelected = useCallback(
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
const keyframeId = getSelectedKeyframeId({ keyframe });
return selectedKeyframes.some(
(selectedKeyframe) =>
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
);
},
[selectedKeyframes],
);
const setKeyframeSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
editor.selection.setSelectedKeyframes({
keyframes: uniqueKeyframes,
anchorKeyframe:
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
});
},
[editor],
);
const addKeyframesToSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const mergedKeyframes = mergeUniqueKeyframes({
keyframes: [...selectedKeyframes, ...keyframes],
});
editor.selection.setSelectedKeyframes({
keyframes: mergedKeyframes,
anchorKeyframe:
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const removeKeyframesFromSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const keyframeIdsToRemove = new Set(
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const nextKeyframes = selectedKeyframes.filter(
(selectedKeyframe) =>
!keyframeIdsToRemove.has(
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
),
);
editor.selection.setSelectedKeyframes({
keyframes: nextKeyframes,
anchorKeyframe:
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const clearKeyframeSelection = useCallback(() => {
editor.selection.clearKeyframeSelection();
}, [editor]);
const toggleKeyframeSelection = useCallback(
({
keyframes,
isMultiKey,
}: {
keyframes: SelectedKeyframeRef[];
isMultiKey: boolean;
}) => {
const anchorKeyframe = keyframes[0];
if (!isMultiKey) {
setKeyframeSelection({ keyframes, anchorKeyframe });
return;
}
const areAllKeyframesSelected = keyframes.every((keyframe) =>
isKeyframeSelected({ keyframe }),
);
if (areAllKeyframesSelected) {
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
return;
}
addKeyframesToSelection({ keyframes, anchorKeyframe });
},
[
setKeyframeSelection,
isKeyframeSelected,
removeKeyframesFromSelection,
addKeyframesToSelection,
],
);
const selectKeyframeRange = useCallback(
({
orderedKeyframes,
targetKeyframes,
isAdditive,
}: {
orderedKeyframes: SelectedKeyframeRef[];
targetKeyframes: SelectedKeyframeRef[];
isAdditive: boolean;
}) => {
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
return;
}
const anchorKeyframe =
keyframeSelectionAnchor ??
selectedKeyframes[selectedKeyframes.length - 1] ??
targetKeyframes[0];
if (!anchorKeyframe) {
return;
}
const targetKeyframeIds = new Set(
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
const anchorIndex = orderedKeyframes.findIndex(
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
);
if (anchorIndex === -1) {
if (isAdditive) {
addKeyframesToSelection({
keyframes: targetKeyframes,
anchorKeyframe,
});
return;
}
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
return;
}
const targetIndexes = orderedKeyframes
.map((keyframe, index) => ({
keyframeId: getSelectedKeyframeId({ keyframe }),
index,
}))
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
.map(({ index }) => index);
if (targetIndexes.length === 0) {
return;
}
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
if (isAdditive) {
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
return;
}
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
},
[
keyframeSelectionAnchor,
selectedKeyframes,
addKeyframesToSelection,
setKeyframeSelection,
],
);
return {
selectedKeyframes,
keyframeSelectionAnchor,
isKeyframeSelected,
setKeyframeSelection,
addKeyframesToSelection,
removeKeyframesFromSelection,
clearKeyframeSelection,
toggleKeyframeSelection,
selectKeyframeRange,
};
}

View File

@ -10,9 +10,12 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import {
findSnapPoints,
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import type { Bookmark } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
export interface BookmarkDragState {
isDragging: boolean;
@ -47,8 +50,6 @@ export function useBookmarkDrag({
const playheadTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false,
bookmarkTime: null,
@ -112,16 +113,7 @@ export function useBookmarkDrag({
snapPoint: result.snapPoint,
};
},
[
snappingEnabled,
findSnapPoints,
snapToNearestPoint,
tracks,
playheadTime,
bookmarks,
zoomLevel,
isShiftHeldRef,
],
[snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {

View File

@ -5,6 +5,7 @@ import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement | null>;
headerRef: React.RefObject<HTMLElement | null>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[],
) => void;
@ -88,6 +89,7 @@ function isRectangleIntersecting({
export function useSelectionBox({
containerRef,
headerRef,
onSelectionComplete,
isEnabled = true,
tracksScrollRef,
@ -131,6 +133,8 @@ export function useSelectionBox({
endPos,
});
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const timelineHeaderHeight =
headerRef.current?.getBoundingClientRect().height ?? 0;
const selectedElements: { trackId: string; elementId: string }[] = [];
for (const [trackIndex, track] of tracks.entries()) {
@ -139,8 +143,9 @@ export function useSelectionBox({
trackIndex,
});
const trackHeight = getTrackHeight({ type: track.type });
const elementTop = trackTop;
const elementBottom = trackTop + trackHeight;
const elementTop =
timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
@ -168,7 +173,14 @@ export function useSelectionBox({
}
onSelectionComplete(selectedElements);
},
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
[
containerRef,
headerRef,
onSelectionComplete,
tracks,
tracksScrollRef,
zoomLevel,
],
);
useEffect(() => {

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
interface UseSnapIndicatorPositionParams {
@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({
? trackLabelsRef.current.offsetWidth
: 0;
const timelinePosition =
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const timelinePosition = timelineTimeToSnappedPixels({
time: snapPoint?.time ?? 0,
zoomLevel,
});
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
return {

View File

@ -8,6 +8,7 @@ import {
buildTextElement,
buildStickerElement,
buildElementFromMedia,
buildEffectElement,
} from "@/lib/timeline/element-utils";
import type { Command } from "@/lib/commands/base-command";
import { AddMediaAssetCommand } from "@/lib/commands/media";
@ -16,7 +17,11 @@ import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
import type { MediaDragData, StickerDragData } from "@/types/drag";
import type {
MediaDragData,
StickerDragData,
EffectDragData,
} from "@/types/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
@ -54,6 +59,7 @@ export function useTimelineDragDrop({
if (dragData.type === "text") return "text";
if (dragData.type === "sticker") return "sticker";
if (dragData.type === "effect") return "effect";
if (dragData.type === "media") {
return dragData.mediaType;
}
@ -70,7 +76,11 @@ export function useTimelineDragDrop({
elementType: ElementType;
mediaId?: string;
}): number => {
if (elementType === "text" || elementType === "sticker") {
if (
elementType === "text" ||
elementType === "sticker" ||
elementType === "effect"
) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
@ -124,6 +134,13 @@ export function useTimelineDragDrop({
const mouseX = e.clientX - rect.left;
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
const targetElementTypes =
dragData?.type === "effect"
? (dragData as EffectDragData).targetElementTypes
: dragData?.type === "media"
? (dragData as MediaDragData).targetElementTypes
: undefined;
const target = computeDropTarget({
elementType,
mouseX,
@ -134,6 +151,7 @@ export function useTimelineDragDrop({
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
targetElementTypes,
});
target.xPosition = getSnappedTime({ time: target.xPosition });
@ -248,6 +266,11 @@ export function useTimelineDragDrop({
const executeMediaDrop = useCallback(
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
if (target.targetElement) {
toast.info("Replace media source is coming soon!");
return;
}
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
@ -284,6 +307,42 @@ export function useTimelineDragDrop({
[editor.timeline, mediaAssets, tracks],
);
const executeEffectDrop = useCallback(
({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
const effectTrack = tracks.find((t) => t.type === "effect");
let trackId: string;
if (effectTrack && !target.targetElement) {
trackId = effectTrack.id;
} else if (target.targetElement) {
trackId = effectTrack?.id ?? editor.timeline.addTrack({
type: "effect",
index: 0,
});
} else if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: "effect",
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track || track.type !== "effect") return;
trackId = track.id;
}
const element = buildEffectElement({
effectType: dragData.effectType,
startTime: target.xPosition,
});
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
[editor.timeline, tracks],
);
const executeFileDrop = useCallback(
async ({
files,
@ -384,6 +443,11 @@ export function useTimelineDragDrop({
executeTextDrop({ target: currentTarget, dragData });
} else if (dragData.type === "sticker") {
executeStickerDrop({ target: currentTarget, dragData });
} else if (dragData.type === "effect") {
executeEffectDrop({
target: currentTarget,
dragData: dragData as EffectDragData,
});
} else {
executeMediaDrop({ target: currentTarget, dragData });
}
@ -410,6 +474,7 @@ export function useTimelineDragDrop({
executeTextDrop,
executeStickerDrop,
executeMediaDrop,
executeEffectDrop,
executeFileDrop,
containerRef,
headerRef,

View File

@ -4,9 +4,9 @@ import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
useTimelineSnapping,
type SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
findSnapPoints,
snapToNearestPoint,
} from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@ -31,10 +31,6 @@ export function useTimelinePlayhead({
const isPlaying = editor.playback.getIsPlaying();
const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
const { snapToNearestPoint } = useTimelineSnapping({
enableElementSnapping: false,
enablePlayheadSnapping: false,
});
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
@ -51,7 +47,13 @@ export function useTimelinePlayhead({
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
const handleScrub = useCallback(
({ event }: { event: MouseEvent | React.MouseEvent }) => {
({
event,
snappingEnabled = true,
}: {
event: MouseEvent | React.MouseEvent;
snappingEnabled?: boolean;
}) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rulerRect = ruler.getBoundingClientRect();
@ -80,21 +82,25 @@ export function useTimelinePlayhead({
fps: framesPerSecond,
});
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({
time: bookmark.time,
type: "bookmark",
}));
const shouldSnapToBookmark =
!isShiftHeldRef.current && bookmarkSnapPoints.length > 0;
const snapResult = shouldSnapToBookmark
? snapToNearestPoint({
targetTime: frameTime,
snapPoints: bookmarkSnapPoints,
zoomLevel,
})
: null;
const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks();
const bookmarks =
editor.scenes.getActiveScene()?.bookmarks ?? [];
const snapPoints = findSnapPoints({
tracks,
playheadTime: frameTime,
bookmarks,
enablePlayheadSnapping: false,
});
const snapResult = snapToNearestPoint({
targetTime: frameTime,
snapPoints,
zoomLevel,
});
return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
})();
setScrubTime(time);
seek({ time });
@ -109,7 +115,7 @@ export function useTimelinePlayhead({
activeProject.settings.fps,
isShiftHeldRef,
editor.scenes,
snapToNearestPoint,
editor.timeline,
],
);
@ -133,10 +139,10 @@ export function useTimelinePlayhead({
setIsDraggingRuler(true);
setHasDraggedRuler(false);
editor.playback.setScrubbing({ isScrubbing: true });
handleScrub({ event });
},
[handleScrub, playheadRef, editor.playback],
editor.playback.setScrubbing({ isScrubbing: true });
handleScrub({ event, snappingEnabled: false });
},
[handleScrub, playheadRef, editor.playback],
);
const handlePlayheadMouseDownEvent = useCallback(
@ -184,7 +190,7 @@ export function useTimelinePlayhead({
if (isDraggingRuler) {
setIsDraggingRuler(false);
if (!hasDraggedRuler) {
handleScrub({ event });
handleScrub({ event, snappingEnabled: false });
}
setHasDraggedRuler(false);
}

View File

@ -1,185 +0,0 @@
import { useCallback } from "react";
import type { Bookmark, TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
export interface SnapPoint {
time: number;
type: "element-start" | "element-end" | "playhead" | "bookmark";
elementId?: string;
trackId?: string;
}
export interface SnapResult {
snappedTime: number;
snapPoint: SnapPoint | null;
snapDistance: number;
}
export interface UseTimelineSnappingOptions {
snapThreshold?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
enableBookmarkSnapping?: boolean;
}
export function useTimelineSnapping({
snapThreshold = 10,
enableElementSnapping = true,
enablePlayheadSnapping = true,
enableBookmarkSnapping = true,
}: UseTimelineSnappingOptions = {}) {
const findSnapPoints = useCallback(
({
tracks,
playheadTime,
excludeElementId,
bookmarks = [],
excludeBookmarkTime,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number;
}): SnapPoint[] => {
const snapPoints: SnapPoint[] = [];
if (enableElementSnapping) {
for (const track of tracks) {
for (const element of track.elements) {
if (element.id === excludeElementId) continue;
const elementStart = element.startTime;
const elementEnd = element.startTime + element.duration;
snapPoints.push(
{
time: elementStart,
type: "element-start",
elementId: element.id,
trackId: track.id,
},
{
time: elementEnd,
type: "element-end",
elementId: element.id,
trackId: track.id,
},
);
}
}
}
if (enablePlayheadSnapping) {
snapPoints.push({
time: playheadTime,
type: "playhead",
});
}
if (enableBookmarkSnapping) {
for (const bookmark of bookmarks) {
if (
excludeBookmarkTime != null &&
Math.abs(bookmark.time - excludeBookmarkTime) <
BOOKMARK_TIME_EPSILON
) {
continue;
}
snapPoints.push({
time: bookmark.time,
type: "bookmark",
});
}
}
return snapPoints;
},
[enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping],
);
const snapToNearestPoint = useCallback(
({
targetTime,
snapPoints,
zoomLevel,
}: {
targetTime: number;
snapPoints: Array<SnapPoint>;
zoomLevel: number;
}): SnapResult => {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
let closestSnapPoint: SnapPoint | null = null;
let closestDistance = Infinity;
for (const snapPoint of snapPoints) {
const distance = Math.abs(targetTime - snapPoint.time);
if (distance < thresholdInSeconds && distance < closestDistance) {
closestDistance = distance;
closestSnapPoint = snapPoint;
}
}
return {
snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
snapPoint: closestSnapPoint,
snapDistance: closestDistance,
};
},
[snapThreshold],
);
const snapElementEdge = useCallback(
({
targetTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId,
snapToStart = true,
bookmarks = [],
}: {
targetTime: number;
elementDuration: number;
tracks: Array<TimelineTrack>;
playheadTime: number;
zoomLevel: number;
excludeElementId?: string;
snapToStart?: boolean;
bookmarks?: Array<Bookmark>;
}): SnapResult => {
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId,
bookmarks,
});
const effectiveTargetTime = snapToStart
? targetTime
: targetTime + elementDuration;
const snapResult = snapToNearestPoint({
targetTime: effectiveTargetTime,
snapPoints,
zoomLevel,
});
if (!snapToStart && snapResult.snapPoint) {
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
}
return snapResult;
},
[findSnapPoints, snapToNearestPoint],
);
return {
snapElementEdge,
findSnapPoints,
snapToNearestPoint,
};
}

View File

@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import type { EffectParamValues } from "@/types/effects";
export function useEffectPreview({
effectType,
params,
canvasRef,
isActive,
}: {
effectType: string;
params: EffectParamValues;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
isActive: boolean;
}): void {
const requestRef = useRef<number>(0);
useEffect(() => {
if (!isActive) {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = 0;
}
return;
}
const loop = (): void => {
const canvas = canvasRef.current;
if (canvas) {
effectPreviewService.renderPreview({
effectType,
params,
targetCanvas: canvas,
});
}
requestRef.current = requestAnimationFrame(loop);
};
requestRef.current = requestAnimationFrame(loop);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [effectType, params, canvasRef, isActive]);
}

View File

@ -4,9 +4,16 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import type { TextElement, Transform } from "@/types/timeline";
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
import { hitTest } from "@/lib/preview/hit-test";
import { screenToCanvas } from "@/lib/preview/preview-coords";
import {
screenPixelsToLogicalThreshold,
screenToCanvas,
} from "@/lib/preview/preview-coords";
import { isVisualElement } from "@/lib/timeline/element-utils";
import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap";
import {
SNAP_THRESHOLD_SCREEN_PIXELS,
snapPosition,
type SnapLine,
} from "@/lib/preview/preview-snap";
const MIN_DRAG_DISTANCE = 0.5;
@ -230,11 +237,16 @@ export function usePreviewInteraction({
};
const shouldSnap = !isShiftHeldRef.current;
const snapThreshold = screenPixelsToLogicalThreshold({
canvas: canvasRef.current,
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
elementSize: dragStateRef.current.bounds,
snapThreshold,
})
: {
snappedPosition: proposedPosition,

View File

@ -6,9 +6,13 @@ import {
getVisibleElementsWithBounds,
type ElementWithBounds,
} from "@/lib/preview/element-bounds";
import { screenToCanvas } from "@/lib/preview/preview-coords";
import {
screenPixelsToLogicalThreshold,
screenToCanvas,
} from "@/lib/preview/preview-coords";
import {
MIN_SCALE,
SNAP_THRESHOLD_SCREEN_PIXELS,
snapRotation,
snapScale,
type SnapLine,
@ -228,6 +232,10 @@ export function useTransformHandles({
);
const canvasSize = editor.project.getActive().settings.canvasSize;
const snapThreshold = screenPixelsToLogicalThreshold({
canvas: canvasRef.current,
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const shouldSnap = !isShiftHeldRef.current;
const { snappedScale, activeLines } = shouldSnap
? snapScale({
@ -236,6 +244,7 @@ export function useTransformHandles({
baseWidth,
baseHeight,
canvasSize,
snapThreshold,
})
: { snappedScale: proposedScale, activeLines: [] as SnapLine[] };

View File

@ -0,0 +1,313 @@
import { describe, expect, test } from "bun:test";
import type { ElementAnimations } from "@/types/animation";
import {
clampAnimationsToDuration,
getElementKeyframes,
getKeyframeAtTime,
hasKeyframesForPath,
getChannelValueAtTime,
getElementLocalTime,
resolveTransformAtTime,
splitAnimationsAtTime,
} from "@/lib/animation";
describe("transform keyframe evaluation", () => {
test("uses fallback value when channel is missing", () => {
const value = getChannelValueAtTime({
channel: undefined,
time: 1,
fallbackValue: 42,
});
expect(value).toBe(42);
});
test("returns boundary value when time is within epsilon of first/last keyframe", () => {
const channel = {
valueKind: "number" as const,
keyframes: [
{ id: "a", time: 0, value: 10, interpolation: "linear" as const },
{ id: "b", time: 2, value: 30, interpolation: "linear" as const },
],
};
expect(
getChannelValueAtTime({
channel,
time: 0.0008,
fallbackValue: 0,
}),
).toBe(10);
expect(
getChannelValueAtTime({
channel,
time: 1.9992,
fallbackValue: 0,
}),
).toBe(30);
});
test("interpolates linear channels", () => {
const value = getChannelValueAtTime({
channel: {
valueKind: "number",
keyframes: [
{ id: "a", time: 0, value: 10, interpolation: "linear" },
{ id: "b", time: 2, value: 30, interpolation: "linear" },
],
},
time: 1,
fallbackValue: 0,
});
expect(value).toBe(20);
});
test("clamps local time to [0, duration]", () => {
expect(
getElementLocalTime({
timelineTime: 2,
elementStartTime: 5,
elementDuration: 4,
}),
).toBe(0);
expect(
getElementLocalTime({
timelineTime: 12,
elementStartTime: 5,
elementDuration: 4,
}),
).toBe(4);
expect(
getElementLocalTime({
timelineTime: 7,
elementStartTime: 5,
elementDuration: 4,
}),
).toBe(2);
});
test("uses hold interpolation from the left keyframe", () => {
const value = getChannelValueAtTime({
channel: {
valueKind: "number",
keyframes: [
{ id: "a", time: 0, value: 10, interpolation: "hold" },
{ id: "b", time: 2, value: 30, interpolation: "linear" },
],
},
time: 1,
fallbackValue: 0,
});
expect(value).toBe(10);
});
test("resolves transform by mixing animated and fallback properties", () => {
const animations: ElementAnimations = {
channels: {
"transform.position.x": {
valueKind: "number",
keyframes: [
{ id: "a", time: 0, value: 0, interpolation: "linear" },
{ id: "b", time: 4, value: 80, interpolation: "linear" },
],
},
"transform.scale": {
valueKind: "number",
keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }],
},
},
};
const resolvedTransform = resolveTransformAtTime({
baseTransform: {
position: { x: 10, y: 20 },
scale: 1,
rotate: 15,
},
animations,
localTime: 2,
});
expect(resolvedTransform).toEqual({
position: { x: 40, y: 20 },
scale: 2,
rotate: 15,
});
});
});
describe("transform keyframe mutation utilities", () => {
test("splits channels and rebases right side times", () => {
const animations: ElementAnimations = {
channels: {
"transform.scale": {
valueKind: "number",
keyframes: [
{ id: "a", time: 0, value: 1, interpolation: "linear" },
{ id: "b", time: 2, value: 2, interpolation: "linear" },
{ id: "c", time: 6, value: 4, interpolation: "linear" },
],
},
},
};
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations,
splitTime: 4,
});
expect(
leftAnimations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 2, 4]);
expect(
rightAnimations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 2]);
expect(
rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value,
).toBe(3);
});
test("clamps channels to updated element duration", () => {
const animations: ElementAnimations = {
channels: {
"transform.rotate": {
valueKind: "number",
keyframes: [
{ id: "a", time: 0, value: 0, interpolation: "linear" },
{ id: "b", time: 2, value: 20, interpolation: "linear" },
{ id: "c", time: 5, value: 50, interpolation: "linear" },
],
},
},
};
const clampedAnimations = clampAnimationsToDuration({
animations,
duration: 2,
});
expect(
clampedAnimations?.channels["transform.rotate"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 2]);
});
});
describe("typed channel interpolation", () => {
test("interpolates color channels from hex keyframes", () => {
const value = getChannelValueAtTime({
channel: {
valueKind: "color",
keyframes: [
{ id: "a", time: 0, value: "#000000", interpolation: "linear" },
{ id: "b", time: 1, value: "#ffffff", interpolation: "linear" },
],
},
time: 0.5,
fallbackValue: "#000000",
});
expect(typeof value).toBe("string");
expect(value).toContain("rgba(");
});
test("uses hold behavior for discrete channels", () => {
const value = getChannelValueAtTime({
channel: {
valueKind: "discrete",
keyframes: [
{ id: "a", time: 0, value: "normal", interpolation: "hold" },
{ id: "b", time: 2, value: "multiply", interpolation: "hold" },
],
},
time: 1.2,
fallbackValue: "normal",
});
expect(value).toBe("normal");
});
});
describe("keyframe query helpers", () => {
test("getElementKeyframes returns flat list of all keyframes across channels", () => {
const animations: ElementAnimations = {
channels: {
"transform.position.x": {
valueKind: "number",
keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
},
opacity: {
valueKind: "number",
keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }],
},
},
};
const keyframes = getElementKeyframes({ animations });
expect(keyframes).toHaveLength(2);
expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([
"opacity",
"transform.position.x",
]);
});
test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => {
expect(getElementKeyframes({ animations: undefined })).toEqual([]);
expect(
getElementKeyframes({
animations: {
channels: { opacity: { valueKind: "number", keyframes: [] } },
},
}),
).toEqual([]);
});
test("hasKeyframesForPath returns true only for paths with keyframes", () => {
const animations: ElementAnimations = {
channels: {
"transform.position.x": {
valueKind: "number",
keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
},
"transform.position.y": {
valueKind: "number",
keyframes: [],
},
},
};
expect(
hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }),
).toBe(true);
expect(
hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }),
).toBe(false);
});
test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => {
const animations: ElementAnimations = {
channels: {
"transform.rotate": {
valueKind: "number",
keyframes: [
{ id: "r-1", time: 1, value: 15, interpolation: "linear" },
{ id: "r-2", time: 2, value: 30, interpolation: "linear" },
],
},
},
};
const found = getKeyframeAtTime({
animations,
propertyPath: "transform.rotate",
time: 1.0008,
});
expect(found?.id).toBe("r-1");
expect(found?.value).toBe(15);
expect(found?.propertyPath).toBe("transform.rotate");
expect(
getKeyframeAtTime({
animations,
propertyPath: "transform.rotate",
time: 1.01,
}),
).toBeNull();
});
});

View File

@ -0,0 +1,39 @@
export {
getChannelValueAtTime,
getNumberChannelValueAtTime,
normalizeChannel,
} from "./interpolation";
export {
clampAnimationsToDuration,
cloneAnimations,
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
setChannel,
splitAnimationsAtTime,
upsertElementKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveOpacityAtTime,
resolveTransformAtTime,
resolveVolumeAtTime,
} from "./resolve";
export {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
withElementBaseValueForProperty,
} from "./property-registry";
export {
getElementKeyframes,
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";

View File

@ -0,0 +1,370 @@
import type {
AnimationChannel,
AnimationValue,
ColorAnimationChannel,
DiscreteValue,
DiscreteAnimationChannel,
NumberAnimationChannel,
} from "@/types/animation";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
function byTimeAscending({
leftTime,
rightTime,
}: {
leftTime: number;
rightTime: number;
}): number {
return leftTime - rightTime;
}
function isWithinTimePair({
time,
leftTime,
rightTime,
}: {
time: number;
leftTime: number;
rightTime: number;
}): boolean {
return (
time >= leftTime - TIME_EPSILON_SECONDS &&
time <= rightTime + TIME_EPSILON_SECONDS
);
}
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,
progress,
}: {
leftValue: number;
rightValue: number;
progress: number;
}): number {
return leftValue + (rightValue - leftValue) * progress;
}
function interpolateColor({
leftColor,
rightColor,
progress,
}: {
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;
}
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,
}),
});
}
export function normalizeChannel<TChannel extends AnimationChannel>({
channel,
}: {
channel: TChannel;
}): TChannel {
return {
...channel,
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
byTimeAscending({
leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time,
}),
),
} as TChannel;
}
function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({
keyframes,
time,
fallbackValue,
getInterpolatedValue,
}: {
keyframes: TKeyframe[] | undefined;
time: number;
fallbackValue: TValue;
getInterpolatedValue: ({
leftKeyframe,
rightKeyframe,
progress,
}: {
leftKeyframe: TKeyframe;
rightKeyframe: TKeyframe;
progress: number;
}) => TValue;
}): TValue {
if (!keyframes || keyframes.length === 0) {
return fallbackValue;
}
const firstKeyframe = keyframes[0];
const lastKeyframe = keyframes[keyframes.length - 1];
if (!firstKeyframe || !lastKeyframe) {
return fallbackValue;
}
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;
}
export function getNumberChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: NumberAnimationChannel | undefined;
time: number;
fallbackValue: number;
}): number {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
return lerpNumber({
leftValue: leftKeyframe.value,
rightValue: rightKeyframe.value,
progress,
});
},
});
}
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,
});
},
});
}
function getDiscreteValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: DiscreteAnimationChannel | undefined;
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
});
}
export function getChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: AnimationChannel | undefined;
time: number;
fallbackValue: AnimationValue;
}): AnimationValue {
if (!channel || channel.keyframes.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 (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
return getDiscreteValueAtTime({
channel,
time,
fallbackValue,
});
}

View File

@ -0,0 +1,67 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
AnimationPropertyPath,
ElementAnimations,
ElementKeyframe,
} from "@/types/animation";
export function getElementKeyframes({
animations,
}: {
animations: ElementAnimations | undefined;
}): ElementKeyframe[] {
if (!animations) {
return [];
}
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (!channel || channel.keyframes.length === 0) {
return [];
}
return channel.keyframes.map((keyframe) => ({
propertyPath: propertyPath as AnimationPropertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
interpolation: keyframe.interpolation,
}));
},
);
}
export function hasKeyframesForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): boolean {
const channel = animations?.channels[propertyPath];
return Boolean(channel && channel.keyframes.length > 0);
}
export function getKeyframeAtTime({
animations,
propertyPath,
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
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 {
propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
interpolation: keyframe.interpolation,
};
}

View File

@ -0,0 +1,593 @@
import type {
AnimationChannel,
AnimationInterpolation,
AnimationKeyframe,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
ColorAnimationChannel,
DiscreteAnimationChannel,
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
import {
coerceAnimationValueForProperty,
getDefaultInterpolationForProperty,
getAnimationPropertyDefinition,
isAnimationPropertyPath,
} from "./property-registry";
function isNearlySameTime({
leftTime,
rightTime,
}: {
leftTime: number;
rightTime: number;
}): boolean {
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
}
function toAnimation({
channelEntries,
}: {
channelEntries: Array<[string, AnimationChannel]>;
}): ElementAnimations | undefined {
if (channelEntries.length === 0) {
return undefined;
}
return {
channels: Object.fromEntries(channelEntries),
};
}
function toChannel({
keyframes,
valueKind,
}: {
keyframes: AnimationKeyframe[];
valueKind: AnimationValueKind;
}): AnimationChannel {
return normalizeChannel({
channel: {
valueKind,
keyframes,
} as AnimationChannel,
});
}
export function getChannel({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: string;
}): AnimationChannel | undefined {
return animations?.channels[propertyPath];
}
function getInterpolationForChannel({
channel,
interpolation,
}: {
channel: AnimationChannel;
interpolation: AnimationInterpolation | undefined;
}): AnimationInterpolation {
if (channel.valueKind === "discrete") {
return "hold";
}
if (interpolation === "linear" || interpolation === "hold") {
return interpolation;
}
return "linear";
}
function buildKeyframe({
channel,
id,
time,
value,
interpolation,
}: {
channel: AnimationChannel;
id: string;
time: number;
value: AnimationValue;
interpolation: AnimationInterpolation;
}): AnimationKeyframe {
if (channel.valueKind === "number") {
if (typeof value !== "number") {
throw new Error("Number channel keyframes require numeric values");
}
return {
id,
time,
value,
interpolation: interpolation === "hold" ? "hold" : "linear",
};
}
if (channel.valueKind === "color") {
if (typeof value !== "string") {
throw new Error("Color channel keyframes require string values");
}
return {
id,
time,
value,
interpolation: interpolation === "hold" ? "hold" : "linear",
};
}
if (typeof value !== "string" && typeof value !== "boolean") {
throw new Error("Discrete channel keyframes require boolean or string values");
}
return {
id,
time,
value,
interpolation: "hold",
};
}
function createEmptyChannel({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationChannel {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel;
}
if (propertyDefinition.valueKind === "color") {
return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel;
}
return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel;
}
export function upsertKeyframe({
channel,
time,
value,
interpolation,
keyframeId,
}: {
channel: AnimationChannel | undefined;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
const currentKeyframes = channel.keyframes;
const nextKeyframes = [...currentKeyframes];
const nextInterpolation = getInterpolationForChannel({
channel,
interpolation,
});
if (keyframeId) {
const keyframeByIdIndex = nextKeyframes.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeByIdIndex >= 0) {
nextKeyframes[keyframeByIdIndex] = buildKeyframe({
channel,
id: nextKeyframes[keyframeByIdIndex].id,
time,
value,
interpolation: nextInterpolation,
});
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
}
const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: time }),
);
if (keyframeAtTimeIndex >= 0) {
nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({
channel,
id: nextKeyframes[keyframeAtTimeIndex].id,
time: nextKeyframes[keyframeAtTimeIndex].time,
value,
interpolation: nextInterpolation,
});
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
nextKeyframes.push(
buildKeyframe({
channel,
id: keyframeId ?? generateUUID(),
time,
value,
interpolation: nextInterpolation,
}),
);
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
export function removeKeyframe({
channel,
keyframeId,
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
const nextKeyframes = channel.keyframes.filter(
(keyframe) => keyframe.id !== keyframeId,
);
if (nextKeyframes.length === 0) {
return undefined;
}
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
export function retimeKeyframe({
channel,
keyframeId,
time,
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
time: number;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
const keyframeByIdIndex = channel.keyframes.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeByIdIndex < 0) {
return channel;
}
const nextKeyframes = [...channel.keyframes];
nextKeyframes[keyframeByIdIndex] = {
...nextKeyframes[keyframeByIdIndex],
time,
};
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
export function setChannel({
animations,
propertyPath,
channel,
}: {
animations: ElementAnimations | undefined;
propertyPath: string;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
const currentChannels = animations?.channels ?? {};
const nextChannelEntries = Object.entries(currentChannels)
.filter(([path]) => path !== propertyPath)
.filter(([, ch]) => ch && ch.keyframes.length > 0)
.map(([path, ch]) => [path, ch] as [string, AnimationChannel]);
if (channel && channel.keyframes.length > 0) {
nextChannelEntries.push([propertyPath, channel]);
}
return toAnimation({
channelEntries: nextChannelEntries,
});
}
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,
}: {
animations: ElementAnimations | undefined;
shouldRegenerateKeyframeIds?: boolean;
}): ElementAnimations | undefined {
if (!animations) {
return undefined;
}
const clonedEntries = Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (!channel || channel.keyframes.length === 0) {
return [];
}
const clonedKeyframes = channel.keyframes.map((keyframe) => ({
...keyframe,
id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id,
}));
return [
[
propertyPath,
toChannel({
keyframes: clonedKeyframes,
valueKind: channel.valueKind,
}),
] as [string, AnimationChannel],
];
},
);
return toAnimation({
channelEntries: clonedEntries,
});
}
export function clampAnimationsToDuration({
animations,
duration,
}: {
animations: ElementAnimations | undefined;
duration: number;
}): ElementAnimations | undefined {
if (!animations) {
return undefined;
}
const clampedEntries = Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (!channel) {
return [];
}
const nextKeyframes = channel.keyframes.filter(
(keyframe) => keyframe.time >= 0 && keyframe.time <= duration,
);
if (nextKeyframes.length === 0) {
return [];
}
return [
[
propertyPath,
toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
}),
] as [string, AnimationChannel],
];
},
);
return toAnimation({
channelEntries: clampedEntries,
});
}
export function splitAnimationsAtTime({
animations,
splitTime,
shouldIncludeSplitBoundary = true,
}: {
animations: ElementAnimations | undefined;
splitTime: number;
shouldIncludeSplitBoundary?: boolean;
}): {
leftAnimations: ElementAnimations | undefined;
rightAnimations: ElementAnimations | undefined;
} {
if (!animations) {
return { leftAnimations: undefined, rightAnimations: undefined };
}
const leftChannels: Array<[string, AnimationChannel]> = [];
const rightChannels: Array<[string, AnimationChannel]> = [];
for (const [propertyPath, channel] of Object.entries(animations.channels)) {
if (!channel || channel.keyframes.length === 0) {
continue;
}
const normalizedChannel = normalizeChannel({ channel });
let leftKeyframes = normalizedChannel.keyframes.filter(
(keyframe) => keyframe.time <= splitTime,
);
let rightKeyframes = normalizedChannel.keyframes
.filter((keyframe) => keyframe.time >= splitTime)
.map((keyframe) => ({
...keyframe,
time: keyframe.time - splitTime,
}));
const hasBoundaryOnLeft = leftKeyframes.some((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }),
);
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
);
if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) {
const boundaryValue = getChannelValueAtTime({
channel: normalizedChannel,
time: splitTime,
fallbackValue: normalizedChannel.keyframes[0].value,
});
const knownPropertyPath = isAnimationPropertyPath({ propertyPath })
? (propertyPath as AnimationPropertyPath)
: null;
const boundaryInterpolation = knownPropertyPath
? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath })
: normalizedChannel.valueKind === "discrete"
? "hold"
: "linear";
if (!hasBoundaryOnLeft) {
leftKeyframes = [
...leftKeyframes,
buildKeyframe({
channel: normalizedChannel,
id: generateUUID(),
time: splitTime,
value: boundaryValue,
interpolation: boundaryInterpolation,
}),
];
}
if (!hasBoundaryOnRight) {
rightKeyframes = [
buildKeyframe({
channel: normalizedChannel,
id: generateUUID(),
time: 0,
value: boundaryValue,
interpolation: boundaryInterpolation,
}),
...rightKeyframes,
];
}
}
const leftChannel = leftKeyframes.length
? toChannel({
keyframes: leftKeyframes,
valueKind: normalizedChannel.valueKind,
})
: undefined;
const rightChannel = rightKeyframes.length
? toChannel({
keyframes: rightKeyframes,
valueKind: normalizedChannel.valueKind,
})
: undefined;
if (leftChannel) {
leftChannels.push([propertyPath, leftChannel]);
}
if (rightChannel) {
rightChannels.push([propertyPath, rightChannel]);
}
}
return {
leftAnimations: toAnimation({ channelEntries: leftChannels }),
rightAnimations: toAnimation({ channelEntries: rightChannels }),
};
}
export function upsertElementKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): ElementAnimations | undefined {
const coercedValue = coerceAnimationValueForProperty({
propertyPath,
value,
});
if (coercedValue === null) {
return animations;
}
const defaultInterpolation = getDefaultInterpolationForProperty({ propertyPath });
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
const channel = getChannel({ animations, propertyPath });
const targetChannel =
channel && channel.valueKind === propertyDefinition.valueKind
? channel
: createEmptyChannel({ propertyPath });
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value: coercedValue,
interpolation: interpolation ?? defaultInterpolation,
keyframeId,
});
return (
setChannel({
animations,
propertyPath,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
export function removeElementKeyframe({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
const updatedChannel = removeKeyframe({
channel,
keyframeId,
});
return setChannel({
animations,
propertyPath,
channel: updatedChannel,
});
}
export function retimeElementKeyframe({
animations,
propertyPath,
keyframeId,
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
keyframeId: string;
time: number;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
const updatedChannel = retimeKeyframe({
channel,
keyframeId,
time,
});
return setChannel({
animations,
propertyPath,
channel: updatedChannel,
});
}

View File

@ -0,0 +1,20 @@
import type {
AnimationPropertyPath,
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
export function getNumberChannelForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): NumberAnimationChannel | undefined {
const channel = animations?.channels[propertyPath];
if (!channel || channel.valueKind !== "number") {
return undefined;
}
return channel;
}

View File

@ -0,0 +1,230 @@
import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
DiscreteValue,
} from "@/types/animation";
import type { TimelineElement } from "@/types/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import { isVisualElement } from "@/lib/timeline/element-utils";
interface NumericRange {
min?: number;
max?: number;
}
interface AnimationPropertyDefinition {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericRange;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => number | null;
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: number;
}) => TimelineElement;
}
const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.position.x": {
valueKind: "number",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.x : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, x: value },
},
}
: element,
},
"transform.position.y": {
valueKind: "number",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.y : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, y: value },
},
}
: element,
},
"transform.scale": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: MIN_TRANSFORM_SCALE },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scale : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, transform: { ...element.transform, scale: value } }
: element,
},
"transform.rotate": {
valueKind: "number",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, transform: { ...element.transform, rotate: value } }
: element,
},
opacity: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, max: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
isVisualElement(element) ? { ...element, opacity: value } : element,
},
volume: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, max: 1 },
supportsElement: ({ element }) => element.type === "audio",
getValue: ({ element }) =>
element.type === "audio" ? element.volume : null,
setValue: ({ element, value }) =>
element.type === "audio" ? { ...element, volume: value } : element,
},
};
export function isAnimationPropertyPath({
propertyPath,
}: {
propertyPath: string;
}): boolean {
return propertyPath in ANIMATION_PROPERTY_REGISTRY;
}
export function getAnimationPropertyDefinition({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationPropertyDefinition {
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
}
export function supportsAnimationProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): boolean {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.supportsElement({ element });
}
export function getElementBaseValueForProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): AnimationValue | null {
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
return null;
}
return definition.getValue({ element });
}
export function withElementBaseValueForProperty({
element,
propertyPath,
value,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): TimelineElement {
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
if (coercedValue === null || typeof coercedValue !== "number") {
return element;
}
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
return element;
}
return definition.setValue({ element, value: coercedValue });
}
export function getDefaultInterpolationForProperty({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationInterpolation {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.defaultInterpolation;
}
function clampNumericRange({
value,
numericRange,
}: {
value: number;
numericRange: NumericRange | undefined;
}): number {
if (!numericRange) {
return value;
}
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, value));
}
export function coerceAnimationValueForProperty({
propertyPath,
value,
}: {
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): AnimationValue | null {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return clampNumericRange({
value,
numericRange: propertyDefinition.numericRange,
});
}
if (propertyDefinition.valueKind === "color") {
return typeof value === "string" ? value : null;
}
if (typeof value === "string" || typeof value === "boolean") {
return value as DiscreteValue;
}
return null;
}

View File

@ -0,0 +1,111 @@
import type { ElementAnimations } from "@/types/animation";
import type { Transform } from "@/types/timeline";
import { getNumberChannelValueAtTime } from "./interpolation";
import { getNumberChannelForPath } from "./number-channel";
export function getElementLocalTime({
timelineTime,
elementStartTime,
elementDuration,
}: {
timelineTime: number;
elementStartTime: number;
elementDuration: number;
}): number {
const localTime = timelineTime - elementStartTime;
if (localTime <= 0) {
return 0;
}
if (localTime >= elementDuration) {
return elementDuration;
}
return localTime;
}
export function resolveTransformAtTime({
baseTransform,
animations,
localTime,
}: {
baseTransform: Transform;
animations: ElementAnimations | undefined;
localTime: number;
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
position: {
x: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.position.x",
}),
time: safeLocalTime,
fallbackValue: baseTransform.position.x,
}),
y: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.position.y",
}),
time: safeLocalTime,
fallbackValue: baseTransform.position.y,
}),
},
scale: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.scale",
}),
time: safeLocalTime,
fallbackValue: baseTransform.scale,
}),
rotate: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.rotate",
}),
time: safeLocalTime,
fallbackValue: baseTransform.rotate,
}),
};
}
export function resolveOpacityAtTime({
baseOpacity,
animations,
localTime,
}: {
baseOpacity: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "opacity",
}),
time: Math.max(0, localTime),
fallbackValue: baseOpacity,
});
}
export function resolveVolumeAtTime({
baseVolume,
animations,
localTime,
}: {
baseVolume: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "volume",
}),
time: Math.max(0, localTime),
fallbackValue: baseVolume,
});
}

View File

@ -0,0 +1,433 @@
import { afterEach, describe, expect, test } from "bun:test";
import { EditorCore } from "@/core";
import type { TimelineTrack, VideoElement } from "@/types/timeline";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
type MockEditor = {
timeline: {
getTracks: () => TimelineTrack[];
updateTracks: (tracks: TimelineTrack[]) => void;
};
selection: {
getSelectedElements: () => { trackId: string; elementId: string }[];
setSelectedElements: ({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}) => void;
};
};
const originalGetInstance = EditorCore.getInstance;
function mockEditorCore({ editor }: { editor: MockEditor }): void {
(
EditorCore as unknown as {
getInstance: () => EditorCore;
}
).getInstance = () => editor as unknown as EditorCore;
}
function restoreEditorCore(): void {
(
EditorCore as unknown as {
getInstance: typeof EditorCore.getInstance;
}
).getInstance = originalGetInstance;
}
function buildVideoElement(): VideoElement {
return {
id: "element-1",
name: "Clip",
type: "video",
mediaId: "media-1",
duration: 8,
startTime: 1,
trimStart: 0,
trimEnd: 0,
transform: DEFAULT_TRANSFORM,
opacity: 1,
animations: {
channels: {
"transform.scale": {
valueKind: "number",
keyframes: [
{ id: "kf-a", time: 0, value: 1, interpolation: "linear" },
{ id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
{ id: "kf-c", time: 6, value: 2, interpolation: "linear" },
],
},
},
},
};
}
function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
return [
{
id: "track-1",
name: "Main",
type: "video",
elements: [element],
isMain: true,
muted: false,
hidden: false,
},
];
}
afterEach(() => {
restoreEditorCore();
});
describe("keyframe-aware timeline commands", () => {
test("duration updates clamp keyframes beyond the new duration", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpdateElementDurationCommand({
trackId: "track-1",
elementId: "element-1",
duration: 3,
}).execute();
const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
expect(
updatedElement?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 3]);
});
test("trim updates clamp keyframes when duration is changed", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpdateElementTrimCommand({
elementId: "element-1",
trimStart: 0,
trimEnd: 0,
startTime: 1,
duration: 2,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
expect(updatedElement.duration).toBe(2);
expect(
updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0]);
});
test("split rebases right-side keyframes and keeps continuity at split time", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new SplitElementsCommand({
elements: [{ trackId: "track-1", elementId: "element-1" }],
splitTime: 5,
}).execute();
const leftElement = updatedTracks[0].elements.find(
(element) => element.id === "element-1",
) as VideoElement;
const rightElement = updatedTracks[0].elements.find(
(element) => element.id !== "element-1",
) as VideoElement;
expect(
leftElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 3, 4]);
expect(
rightElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 2]);
expect(
rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
).toBeCloseTo(5 / 3, 4);
});
test("duplicate creates independent keyframe ids for copied element", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
setSelectedElements: () => {},
},
},
});
new DuplicateElementsCommand({
elements: [{ trackId: "track-1", elementId: "element-1" }],
}).execute();
const originalElement = updatedTracks.find(
(track) => track.id === "track-1",
)?.elements[0] as VideoElement;
const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
expect(duplicatedElement).toBeDefined();
expect(
duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 3, 6]);
expect(
duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
).not.toBe(
originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
);
});
});
describe("generic keyframe commands", () => {
test("upsert adds or updates keyframe at target time", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpsertKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
time: 2,
value: 2.5,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const keyframes =
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
expect(atTwo?.value).toBe(2.5);
});
test("remove deletes keyframe by id", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new RemoveKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
keyframeId: "kf-b",
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const keyframes =
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
expect(keyframes).toHaveLength(2);
expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
expect(updatedElement.transform.scale).toBe(1);
});
test("remove persists value to base property when channel becomes empty", () => {
const element: VideoElement = {
...buildVideoElement(),
transform: {
...DEFAULT_TRANSFORM,
scale: 1,
},
animations: {
channels: {
"transform.scale": {
valueKind: "number",
keyframes: [
{
id: "only-scale",
time: 2,
value: 1.43,
interpolation: "linear",
},
],
},
},
},
};
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new RemoveKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
keyframeId: "only-scale",
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
expect(updatedElement.transform.scale).toBe(1.43);
expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
});
test("upsert supports non-transform paths like opacity", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpsertKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "opacity",
time: 1,
value: 0.35,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const opacityChannel = updatedElement.animations?.channels.opacity;
expect(opacityChannel?.valueKind).toBe("number");
expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
});
test("retime moves keyframe to new time", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new RetimeKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
keyframeId: "kf-b",
nextTime: 4,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
(existingKeyframe) => existingKeyframe.id === "kf-b",
);
expect(keyframe?.time).toBe(4);
});
});

View File

@ -13,6 +13,7 @@ import {
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@ -176,6 +177,10 @@ function buildPastedElements({
...item.element,
id: newElementId,
startTime,
animations: cloneAnimations({
animations: item.element.animations,
shouldRegenerateKeyframeIds: true,
}),
} as TimelineElement);
}

View File

@ -5,12 +5,19 @@ import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elements: { trackId: string; elementId: string }[];
private readonly rippleEnabled: boolean;
constructor(
private elements: { trackId: string; elementId: string }[],
private rippleEnabled = false,
) {
constructor({
elements,
rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
rippleEnabled?: boolean;
}) {
super();
this.elements = elements;
this.rippleEnabled = rippleEnabled;
}
execute(): void {
@ -19,29 +26,29 @@ export class DeleteElementsCommand extends Command {
const updatedTracks = this.savedState
.map((track) => {
const elementsToDeleteOnTrack = this.elements.filter(
(target) => target.trackId === track.id,
);
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
const elementsToDeleteOnTrack = this.elements.filter(
(target) => target.trackId === track.id,
);
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
if (!hasElementsToDelete) {
return track;
}
if (!hasElementsToDelete) {
return track;
}
const deletedElementInfos = elementsToDeleteOnTrack
.map((target) =>
track.elements.find((element) => element.id === target.elementId),
)
.filter((element): element is NonNullable<typeof element> => element !== undefined)
.map((element) => ({ startTime: element.startTime, duration: element.duration }));
const deletedElementInfos = elementsToDeleteOnTrack
.map((target) =>
track.elements.find((element) => element.id === target.elementId),
)
.filter((element): element is NonNullable<typeof element> => element !== undefined)
.map((element) => ({ startTime: element.startTime, duration: element.duration }));
let elements = track.elements.filter(
(element) =>
!this.elements.some(
(target) =>
target.trackId === track.id && target.elementId === element.id,
),
);
let elements = track.elements.filter(
(element) =>
!this.elements.some(
(target) =>
target.trackId === track.id && target.elementId === element.id,
),
);
if (this.rippleEnabled && deletedElementInfos.length > 0) {
const sortedByStartDesc = [...deletedElementInfos].sort(

View File

@ -6,6 +6,7 @@ import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
(el) => el.trackId === track.id,
(elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
@ -114,5 +115,14 @@ function buildDuplicateElement({
id: string;
startTime: number;
}): TimelineElement {
return { ...element, id, name: `${element.name} (copy)`, startTime };
return {
...element,
id,
name: `${element.name} (copy)`,
startTime,
animations: cloneAnimations({
animations: element.animations,
shouldRegenerateKeyframeIds: true,
}),
};
}

View File

@ -9,3 +9,4 @@ export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";

View File

@ -173,6 +173,11 @@ export class InsertElementCommand extends Command {
return false;
}
if (element.type === "effect" && !element.effectType) {
console.error("Effect element must have effectType");
return false;
}
return true;
}

View File

@ -0,0 +1,3 @@
export * from "./remove-keyframe";
export * from "./retime-keyframe";
export * from "./upsert-keyframe";

View File

@ -0,0 +1,141 @@
import { EditorCore } from "@/core";
import {
getChannel,
getChannelValueAtTime,
getElementBaseValueForProperty,
removeElementKeyframe,
supportsAnimationProperty,
withElementBaseValueForProperty,
} from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
import type { AnimationPropertyPath } from "@/types/animation";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
function sampleValueBeforeRemoval({
element,
propertyPath,
keyframeId,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}): number | null {
const channel = getChannel({
animations: element.animations,
propertyPath,
});
const keyframe = channel?.keyframes.find(
(candidate) => candidate.id === keyframeId,
);
if (!channel || !keyframe) {
return null;
}
const baseValue = getElementBaseValueForProperty({ element, propertyPath });
if (baseValue === null || typeof baseValue !== "number") {
return null;
}
const sampled = getChannelValueAtTime({
channel,
time: keyframe.time,
fallbackValue: baseValue,
});
return typeof sampled === "number" ? sampled : null;
}
function removeKeyframeAndPersist({
element,
propertyPath,
keyframeId,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}): TimelineElement {
const valueBefore = sampleValueBeforeRemoval({
element,
propertyPath,
keyframeId,
});
const nextAnimations = removeElementKeyframe({
animations: element.animations,
propertyPath,
keyframeId,
});
const isChannelNowEmpty =
getChannel({ animations: nextAnimations, propertyPath }) === undefined;
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase
? withElementBaseValueForProperty({
element,
propertyPath,
value: valueBefore,
})
: element;
return { ...baseElement, animations: nextAnimations };
}
export class RemoveKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly keyframeId: string;
constructor({
trackId,
elementId,
propertyPath,
keyframeId,
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.propertyPath = propertyPath;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) =>
removeKeyframeAndPersist({
element,
propertyPath: this.propertyPath,
keyframeId: this.keyframeId,
}),
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}

View File

@ -0,0 +1,76 @@
import { EditorCore } from "@/core";
import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
import type { AnimationPropertyPath } from "@/types/animation";
import type { TimelineTrack } from "@/types/timeline";
export class RetimeKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly keyframeId: string;
private readonly nextTime: number;
constructor({
trackId,
elementId,
propertyPath,
keyframeId,
nextTime,
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
nextTime: number;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.propertyPath = propertyPath;
this.keyframeId = keyframeId;
this.nextTime = nextTime;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
if (!Number.isFinite(boundedTime)) return element;
return {
...element,
animations: retimeElementKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
keyframeId: this.keyframeId,
time: boundedTime,
}),
};
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}

View File

@ -0,0 +1,89 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
} from "@/types/animation";
export class UpsertKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly time: number;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
constructor({
trackId,
elementId,
propertyPath,
time,
value,
interpolation,
keyframeId,
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.propertyPath = propertyPath;
this.time = time;
this.value = value;
this.interpolation = interpolation;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
return {
...element,
animations: upsertElementKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
}),
};
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}

View File

@ -14,15 +14,31 @@ import {
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly sourceTrackId: string;
private readonly targetTrackId: string;
private readonly elementId: string;
private readonly newStartTime: number;
private readonly createTrack: { type: TrackType; index: number } | undefined;
constructor(
private sourceTrackId: string,
private targetTrackId: string,
private elementId: string,
private newStartTime: number,
private createTrack?: { type: TrackType; index: number },
) {
constructor({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}) {
super();
this.sourceTrackId = sourceTrackId;
this.targetTrackId = targetTrackId;
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
}
execute(): void {
@ -30,10 +46,10 @@ export class MoveElementCommand extends Command {
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find(
(t) => t.id === this.sourceTrackId,
(track) => track.id === this.sourceTrackId,
);
const element = sourceTrack?.elements.find(
(el) => el.id === this.elementId,
(trackElement) => trackElement.id === this.elementId,
);
if (!sourceTrack || !element) {
@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
return;
}
let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({
@ -74,6 +90,7 @@ export class MoveElementCommand extends Command {
excludeElementId: this.elementId,
});
// keyframe times remain clip-local, so moving only changes element startTime.
const movedElement: TimelineElement = {
...element,
startTime: adjustedStartTime,
@ -81,12 +98,12 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId;
let updatedTracks = tracksToUpdate.map((track) => {
let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.map((el) =>
el.id === this.elementId ? movedElement : el,
elements: track.elements.map((trackElement) =>
trackElement.id === this.elementId ? movedElement : trackElement,
),
};
}
@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
if (track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.filter((el) => el.id !== this.elementId),
elements: track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
),
};
}
@ -105,8 +124,8 @@ export class MoveElementCommand extends Command {
};
}
return track;
}) as TimelineTrack[];
return track;
});
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(

View File

@ -3,19 +3,33 @@ import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline";
import { splitAnimationsAtTime } from "@/lib/animation";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right";
private readonly rippleEnabled: boolean;
constructor(
private elements: { trackId: string; elementId: string }[],
private splitTime: number,
private retainSide: "both" | "left" | "right" = "both",
private rippleEnabled = false,
) {
constructor({
elements,
splitTime,
retainSide = "both",
rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
rippleEnabled?: boolean;
}) {
super();
this.elements = elements;
this.splitTime = splitTime;
this.retainSide = retainSide;
this.rippleEnabled = rippleEnabled;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
@ -61,6 +75,11 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
shouldIncludeSplitBoundary: true,
});
if (this.retainSide === "left") {
return [
@ -69,6 +88,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
animations: leftAnimations,
},
];
}
@ -90,11 +110,13 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
animations: rightAnimations,
},
];
}
const secondElementId = generateUUID();
// "both" - split into two pieces
const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
@ -106,6 +128,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
animations: leftAnimations,
},
{
...element,
@ -114,14 +137,12 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
animations: rightAnimations,
},
];
});
if (
this.rippleEnabled &&
leftVisibleDurationForRipple !== null
) {
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
elements = rippleShiftElements({
elements,
afterTime: this.splitTime,

View File

@ -1,28 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementDurationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly duration: number;
constructor(
private trackId: string,
private elementId: string,
private duration: number,
) {
constructor({
trackId,
elementId,
duration,
}: {
trackId: string;
elementId: string;
duration: number;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.duration = duration;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId ? { ...el, duration: this.duration } : el,
const updatedTracks = this.savedState.map((track) => {
if (track.id !== this.trackId) return track;
const newElements = track.elements.map((element) =>
element.id === this.elementId
? {
...element,
duration: this.duration,
animations: clampAnimationsToDuration({
animations: element.animations,
duration: this.duration,
}),
}
: element,
);
return { ...t, elements: newElements } as typeof t;
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);

View File

@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elements: { trackId: string; elementId: string }[];
private readonly startTime: number;
constructor(
private elements: { trackId: string; elementId: string }[],
private startTime: number,
) {
constructor({
elements,
startTime,
}: {
elements: { trackId: string; elementId: string }[];
startTime: number;
}) {
super();
this.elements = elements;
this.startTime = startTime;
}
execute(): void {
@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
const currentTracks = this.savedState;
const updatedTracks = currentTracks.map((track) => {
const hasElementsToUpdate = this.elements.some(
(el) => el.trackId === track.id,
(elementEntry) => elementEntry.trackId === track.id,
);
if (!hasElementsToUpdate) {
@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
(el) => el.elementId === element.id && el.trackId === track.id,
(elementEntry) =>
elementEntry.elementId === element.id &&
elementEntry.trackId === track.id,
);
if (!shouldUpdate) {
return element;

View File

@ -1,18 +1,35 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elementId: string;
private readonly trimStart: number;
private readonly trimEnd: number;
private readonly startTime: number | undefined;
private readonly duration: number | undefined;
constructor(
private elementId: string,
private trimStart: number,
private trimEnd: number,
private startTime?: number,
private duration?: number,
) {
constructor({
elementId,
trimStart,
trimEnd,
startTime,
duration,
}: {
elementId: string;
trimStart: number;
trimEnd: number;
startTime?: number;
duration?: number;
}) {
super();
this.elementId = elementId;
this.trimStart = trimStart;
this.trimEnd = trimEnd;
this.startTime = startTime;
this.duration = duration;
}
execute(): void {
@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
return element;
}
const nextDuration = this.duration ?? element.duration;
return {
...element,
trimStart: this.trimStart,
trimEnd: this.trimEnd,
startTime: this.startTime ?? element.startTime,
duration: this.duration ?? element.duration,
duration: nextDuration,
animations: clampAnimationsToDuration({
animations: element.animations,
duration: nextDuration,
}),
};
});
return { ...track, elements: newElements } as typeof track;

View File

@ -1,28 +1,38 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { updateElementInTracks } from "@/lib/timeline";
export class UpdateElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly updates: Partial<TimelineElement>;
constructor(
private trackId: string,
private elementId: string,
private updates: Partial<Record<string, unknown>>,
) {
constructor({
trackId,
elementId,
updates,
}: {
trackId: string;
elementId: string;
updates: Partial<TimelineElement>;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.updates = updates;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId ? { ...el, ...this.updates } : el,
);
return { ...t, elements: newElements } as typeof t;
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
update: (element) => ({ ...element, ...this.updates }) as TimelineElement,
});
editor.timeline.updateTracks(updatedTracks);

View File

@ -0,0 +1,25 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_sigma;
uniform vec2 u_direction;
varying vec2 v_texCoord;
void main() {
vec2 texelSize = 1.0 / u_resolution;
vec4 color = vec4(0.0);
float totalWeight = 0.0;
// step=1 texel — scaling step size instead causes discrete ghosting artifacts
for (int i = -30; i <= 30; i++) {
float fi = float(i);
float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight;
totalWeight += weight;
}
gl_FragColor = color / totalWeight;
}

View File

@ -0,0 +1,50 @@
import type { EffectDefinition } from "@/types/effects";
import blurFragmentShader from "./blur.frag.glsl";
export const blurEffectDefinition: EffectDefinition = {
type: "blur",
name: "Blur",
keywords: ["blur", "soft", "defocus"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 15,
min: 0,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams }) => {
const intensity =
typeof effectParams.intensity === "number"
? effectParams.intensity
: Number.parseFloat(String(effectParams.intensity));
return {
u_sigma: Math.max(intensity / 5, 0.001),
u_direction: [1, 0],
};
},
},
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams }) => {
const intensity =
typeof effectParams.intensity === "number"
? effectParams.intensity
: Number.parseFloat(String(effectParams.intensity));
return {
u_sigma: Math.max(intensity / 5, 0.001),
u_direction: [0, 1],
};
},
},
],
},
};

View File

@ -0,0 +1,13 @@
import { hasEffect, registerEffect } from "../registry";
import { blurEffectDefinition } from "./blur";
const defaultEffects = [blurEffectDefinition];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {
if (hasEffect({ effectType: definition.type })) {
continue;
}
registerEffect({ definition });
}
}

View File

@ -0,0 +1,7 @@
attribute vec2 a_position;
varying vec2 v_texCoord;
void main() {
v_texCoord = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}

View File

@ -0,0 +1,34 @@
import { generateUUID } from "@/utils/id";
import { getEffect } from "./registry";
import type { Effect, EffectParamValues } from "@/types/effects";
import type { VisualElement } from "@/types/timeline";
export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
export { registerDefaultEffects } from "./definitions";
export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
"video",
"image",
"text",
"sticker",
];
export function buildDefaultEffectInstance({
effectType,
}: {
effectType: string;
}): Effect {
const definition = getEffect({ effectType });
const params: EffectParamValues = {};
for (const paramDef of definition.params) {
params[paramDef.key] = paramDef.default;
}
return {
id: generateUUID(),
type: effectType,
params,
enabled: true,
};
}

View File

@ -0,0 +1,31 @@
import type { EffectDefinition } from "@/types/effects";
const effectDefinitions = new Map<string, EffectDefinition>();
export function registerEffect({
definition,
}: {
definition: EffectDefinition;
}): void {
effectDefinitions.set(definition.type, definition);
}
export function hasEffect({ effectType }: { effectType: string }): boolean {
return effectDefinitions.has(effectType);
}
export function getEffect({
effectType,
}: {
effectType: string;
}): EffectDefinition {
const definition = effectDefinitions.get(effectType);
if (!definition) {
throw new Error(`Unknown effect type: ${effectType}`);
}
return definition;
}
export function getAllEffects(): EffectDefinition[] {
return Array.from(effectDefinitions.values());
}

View File

@ -8,19 +8,23 @@ import type { MediaAsset } from "@/types/assets";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
const MAX_AUDIO_CHANNELS = 2;
const EXPORT_SAMPLE_RATE = 44100;
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
> & { buffer: AudioBuffer };
export function createAudioContext(): AudioContext {
export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext {
const AudioContextConstructor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
return new AudioContextConstructor();
return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
}
export interface DecodedAudio {
@ -170,12 +174,64 @@ async function resolveAudioBufferForVideoElement({
mediaAsset: MediaAsset;
audioContext: AudioContext;
}): Promise<AudioBuffer | null> {
const input = new Input({
source: new BlobSource(mediaAsset.file),
formats: ALL_FORMATS,
});
try {
const arrayBuffer = await mediaAsset.file.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) return null;
const sink = new AudioBufferSink(audioTrack);
const targetSampleRate = audioContext.sampleRate;
const chunks: AudioBuffer[] = [];
let totalSamples = 0;
for await (const { buffer } of sink.buffers(0)) {
chunks.push(buffer);
totalSamples += buffer.length;
}
if (chunks.length === 0) return null;
const nativeSampleRate = chunks[0].sampleRate;
const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels);
const nativeChannels = Array.from(
{ length: numChannels },
() => new Float32Array(totalSamples),
);
let offset = 0;
for (const chunk of chunks) {
for (let channel = 0; channel < numChannels; channel++) {
const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1));
nativeChannels[channel].set(sourceData, offset);
}
offset += chunk.length;
}
// use OfflineAudioContext for high-quality resampling to target rate
const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate));
const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate);
const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate);
for (let ch = 0; ch < numChannels; ch++) {
nativeBuffer.copyToChannel(nativeChannels[ch], ch);
}
const sourceNode = offlineContext.createBufferSource();
sourceNode.buffer = nativeBuffer;
sourceNode.connect(offlineContext.destination);
sourceNode.start(0);
return await offlineContext.startRendering();
} catch (error) {
console.warn("Failed to decode video audio:", error);
return null;
} finally {
input.dispose();
}
}
@ -422,7 +478,7 @@ export async function createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
sampleRate = 44100,
sampleRate = EXPORT_SAMPLE_RATE,
audioContext,
}: {
tracks: TimelineTrack[];
@ -431,7 +487,7 @@ export async function createTimelineAudioBuffer({
sampleRate?: number;
audioContext?: AudioContext;
}): Promise<AudioBuffer | null> {
const context = audioContext ?? createAudioContext();
const context = audioContext ?? createAudioContext({ sampleRate });
const audioElements = await collectAudioElements({
tracks,

View File

@ -2,9 +2,12 @@ import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { isMainTrack } from "@/lib/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
export interface ElementBounds {
cx: number;
@ -60,17 +63,24 @@ export function getElementBounds({
element,
canvasSize,
mediaAsset,
localTime,
}: {
element: TimelineElement;
canvasSize: { width: number; height: number };
mediaAsset?: MediaAsset | null;
localTime: number;
}): ElementBounds | null {
if (element.type === "audio") return null;
if (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
if (element.type === "video" || element.type === "image") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const sourceWidth = mediaAsset?.width ?? canvasWidth;
const sourceHeight = mediaAsset?.height ?? canvasHeight;
return getVisualElementBounds({
@ -78,21 +88,31 @@ export function getElementBounds({
canvasHeight,
sourceWidth,
sourceHeight,
transform: element.transform,
transform,
});
}
if (element.type === "sticker") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth: 200,
sourceHeight: 200,
transform: element.transform,
transform,
});
}
if (element.type === "text") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const scaledFontSize =
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const letterSpacing = element.letterSpacing ?? 0;
@ -121,37 +141,46 @@ export function getElementBounds({
const lines = element.content.split("\n");
const lineMetrics = lines.map((line) => ctx.measureText(line));
let top = Number.POSITIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
let maxWidth = 0;
for (let i = 0; i < lineMetrics.length; i++) {
const metrics = lineMetrics[i];
const y = i * lineHeightPx;
top = Math.min(
top,
y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
);
bottom = Math.max(
bottom,
y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
);
maxWidth = Math.max(maxWidth, metrics.width);
}
measuredWidth = maxWidth;
measuredHeight = bottom - top;
const block = measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize: scaledFontSize,
});
const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const visualRect = getTextVisualRect({
textAlign: element.textAlign,
block,
background: element.background,
fontSizeRatio,
});
measuredWidth = visualRect.width;
measuredHeight = visualRect.height;
const localCenterX = visualRect.left + visualRect.width / 2;
const localCenterY = visualRect.top + visualRect.height / 2;
const scaledCenterX = localCenterX * transform.scale;
const scaledCenterY = localCenterY * transform.scale;
const rotationRad = (transform.rotate * Math.PI) / 180;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
return {
cx: canvasWidth / 2 + transform.position.x + rotatedCenterX,
cy: canvasHeight / 2 + transform.position.y + rotatedCenterY,
width: measuredWidth * transform.scale,
height: measuredHeight * transform.scale,
rotation: transform.rotate,
};
}
const width = measuredWidth * element.transform.scale;
const height = measuredHeight * element.transform.scale;
const width = measuredWidth * transform.scale;
const height = measuredHeight * transform.scale;
return {
cx: canvasWidth / 2 + element.transform.position.x,
cy: canvasHeight / 2 + element.transform.position.y,
cx: canvasWidth / 2 + transform.position.x,
cy: canvasHeight / 2 + transform.position.y,
width,
height,
rotation: element.transform.rotate,
rotation: transform.rotate,
};
}
@ -195,6 +224,11 @@ export function getVisibleElementsWithBounds({
});
for (const element of elements) {
const localTime = getElementLocalTime({
timelineTime: currentTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const mediaAsset =
element.type === "video" || element.type === "image"
? mediaMap.get(element.mediaId)
@ -203,6 +237,7 @@ export function getVisibleElementsWithBounds({
element,
canvasSize,
mediaAsset,
localTime,
});
if (bounds) {
result.push({

View File

@ -74,3 +74,17 @@ export function getDisplayScale({
y: canvasRect.height / canvasSize.height,
};
}
export function screenPixelsToLogicalThreshold({
canvas,
screenPixels,
}: {
canvas: HTMLCanvasElement;
screenPixels: number;
}): { x: number; y: number } {
const canvasRect = canvas.getBoundingClientRect();
return {
x: screenPixels * (canvas.width / canvasRect.width),
y: screenPixels * (canvas.height / canvasRect.height),
};
}

View File

@ -3,10 +3,10 @@ export interface SnapLine {
position: number;
}
const SNAP_THRESHOLD = 10;
const ROTATION_SNAP_STEP_DEGREES = 90;
const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
export const MIN_SCALE = 0.01;
export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
export interface SnapResult {
snappedPosition: { x: number; y: number };
@ -17,10 +17,12 @@ export function snapPosition({
proposedPosition,
canvasSize,
elementSize,
snapThreshold,
}: {
proposedPosition: { x: number; y: number };
canvasSize: { width: number; height: number };
elementSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
}): SnapResult {
const centerX = 0;
const centerY = 0;
@ -41,11 +43,13 @@ export function snapPosition({
function getClosestAxisSnap({
candidates,
threshold,
}: {
candidates: AxisSnapCandidate[];
threshold: number;
}): AxisSnapCandidate | null {
const snapCandidatesWithinThreshold = candidates.filter(
(candidate) => candidate.distance <= SNAP_THRESHOLD,
(candidate) => candidate.distance <= threshold,
);
if (snapCandidatesWithinThreshold.length === 0) {
return null;
@ -95,8 +99,14 @@ export function snapPosition({
});
}
const closestX = getClosestAxisSnap({ candidates: xCandidates });
const closestY = getClosestAxisSnap({ candidates: yCandidates });
const closestX = getClosestAxisSnap({
candidates: xCandidates,
threshold: snapThreshold.x,
});
const closestY = getClosestAxisSnap({
candidates: yCandidates,
threshold: snapThreshold.y,
});
const x = closestX?.snappedPosition ?? proposedPosition.x;
const y = closestY?.snappedPosition ?? proposedPosition.y;
@ -124,12 +134,14 @@ export function snapScale({
baseWidth,
baseHeight,
canvasSize,
snapThreshold,
}: {
proposedScale: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
}): ScaleSnapResult {
const centerX = 0;
const centerY = 0;
@ -162,7 +174,7 @@ export function snapScale({
for (const target of verticalTargets) {
const distanceLeft = Math.abs(leftEdge - target.position);
if (distanceLeft <= SNAP_THRESHOLD) {
if (distanceLeft <= snapThreshold.x) {
const scale = (2 * (position.x - target.position)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@ -173,7 +185,7 @@ export function snapScale({
}
}
const distanceRight = Math.abs(rightEdge - target.position);
if (distanceRight <= SNAP_THRESHOLD) {
if (distanceRight <= snapThreshold.x) {
const scale = (2 * (target.position - position.x)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@ -199,7 +211,7 @@ export function snapScale({
for (const target of horizontalTargets) {
const distanceTop = Math.abs(topEdge - target.position);
if (distanceTop <= SNAP_THRESHOLD) {
if (distanceTop <= snapThreshold.y) {
const scale = (2 * (position.y - target.position)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
@ -210,7 +222,7 @@ export function snapScale({
}
}
const distanceBottom = Math.abs(bottomEdge - target.position);
if (distanceBottom <= SNAP_THRESHOLD) {
if (distanceBottom <= snapThreshold.y) {
const scale = (2 * (target.position - position.y)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({

View File

@ -0,0 +1,167 @@
import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
import type { TextElement } from "@/types/timeline";
type TextRect = {
left: number;
top: number;
width: number;
height: number;
};
export interface TextBlockMeasurement {
visualCenterOffset: number;
height: number;
maxWidth: number;
}
export function getMetricAscent({
metrics,
fallbackFontSize,
}: {
metrics: TextMetrics;
fallbackFontSize: number;
}): number {
return metrics.actualBoundingBoxAscent ?? fallbackFontSize * 0.8;
}
export function getMetricDescent({
metrics,
fallbackFontSize,
}: {
metrics: TextMetrics;
fallbackFontSize: number;
}): number {
return metrics.actualBoundingBoxDescent ?? fallbackFontSize * 0.2;
}
export function measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize,
}: {
lineMetrics: TextMetrics[];
lineHeightPx: number;
fallbackFontSize: number;
}): TextBlockMeasurement {
let top = Number.POSITIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
let maxWidth = 0;
for (let index = 0; index < lineMetrics.length; index++) {
const metrics = lineMetrics[index];
const lineY = index * lineHeightPx;
top = Math.min(
top,
lineY - getMetricAscent({ metrics, fallbackFontSize }),
);
bottom = Math.max(
bottom,
lineY + getMetricDescent({ metrics, fallbackFontSize }),
);
maxWidth = Math.max(maxWidth, metrics.width);
}
const height = bottom - top;
const visualCenterOffset = (top + bottom) / 2;
return { visualCenterOffset, height, maxWidth };
}
function getTextRect({
textAlign,
block,
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
}): TextRect {
const left =
textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
return {
left,
top: -block.height / 2,
width: block.maxWidth,
height: block.height,
};
}
function isTextBackgroundVisible({
background,
}: {
background: TextElement["background"];
}): boolean {
return Boolean(background.color) && background.color !== "transparent";
}
export function getTextBackgroundRect({
textAlign,
block,
background,
fontSizeRatio = 1,
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
fontSizeRatio?: number;
}): TextRect | null {
if (!isTextBackgroundVisible({ background })) {
return null;
}
const textRect = getTextRect({ textAlign, block });
const paddingX =
(background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) * fontSizeRatio;
const paddingY =
(background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) * fontSizeRatio;
const offsetX = background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX;
const offsetY = background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY;
return {
left: textRect.left - paddingX + offsetX,
top: textRect.top - paddingY + offsetY,
width: textRect.width + paddingX * 2,
height: textRect.height + paddingY * 2,
};
}
export function getTextVisualRect({
textAlign,
block,
background,
fontSizeRatio = 1,
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
fontSizeRatio?: number;
}): TextRect {
const textRect = getTextRect({ textAlign, block });
const backgroundRect = getTextBackgroundRect({
textAlign,
block,
background,
fontSizeRatio,
});
if (!backgroundRect) {
return textRect;
}
const left = Math.min(textRect.left, backgroundRect.left);
const top = Math.min(textRect.top, backgroundRect.top);
const right = Math.max(
textRect.left + textRect.width,
backgroundRect.left + backgroundRect.width,
);
const bottom = Math.max(
textRect.top + textRect.height,
backgroundRect.top + backgroundRect.height,
);
return {
left,
top,
width: right - left,
height: bottom - top,
};
}

View File

@ -1,9 +1,42 @@
import type { TimelineTrack, ElementType } from "@/types/timeline";
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
import type {
TimelineTrack,
ElementType,
TimelineElement,
} from "@/types/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
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,
@ -16,7 +49,7 @@ function getTrackAtY({
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
const trackHeight = TRACK_HEIGHTS[tracks[i].type];
const trackHeight = TRACK_CONFIG[tracks[i].type].height;
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
@ -55,6 +88,7 @@ function isCompatible({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
@ -100,6 +134,8 @@ function findInsertIndex({
};
}
const EMPTY_TARGET_ELEMENT = null;
export function computeDropTarget({
elementType,
mouseX,
@ -113,6 +149,7 @@ export function computeDropTarget({
verticalDragDirection,
startTimeOverride,
excludeElementId,
targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
@ -130,9 +167,16 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
@ -146,6 +190,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@ -155,6 +200,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@ -163,12 +209,37 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
const trackHeight = TRACK_HEIGHTS[track.type];
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 = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
@ -200,6 +271,7 @@ export function computeDropTarget({
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@ -220,6 +292,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: position,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@ -237,7 +310,7 @@ export function getDropLineY({
let y = 0;
for (let i = 0; i < safeTrackIndex; i++) {
y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;

View File

@ -6,6 +6,7 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import type {
CreateEffectElement,
CreateTimelineElement,
CreateVideoElement,
CreateImageElement,
@ -18,10 +19,12 @@ import type {
AudioElement,
VideoElement,
ImageElement,
StickerElement,
VisualElement,
UploadAudioElement,
} from "@/types/timeline";
import type { MediaType } from "@/types/assets";
import { buildDefaultEffectInstance } from "@/lib/effects";
import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
@ -31,7 +34,7 @@ export function canElementHaveAudio(
export function isVisualElement(
element: TimelineElement,
): element is VideoElement | ImageElement | TextElement | StickerElement {
): element is VisualElement {
return (
element.type === "video" ||
element.type === "image" ||
@ -42,7 +45,7 @@ export function isVisualElement(
export function canElementBeHidden(
element: TimelineElement,
): element is VideoElement | ImageElement | TextElement | StickerElement {
): element is VisualElement {
return element.type !== "audio";
}
@ -154,7 +157,14 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
background: {
color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
cornerRadius: t.background?.cornerRadius,
paddingX: t.background?.paddingX,
paddingY: t.background?.paddingY,
offsetX: t.background?.offsetX,
offsetY: t.background?.offsetY,
},
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@ -167,6 +177,28 @@ export function buildTextElement({
};
}
export function buildEffectElement({
effectType,
startTime,
duration,
}: {
effectType: string;
startTime: number;
duration?: number;
}): CreateEffectElement {
const instance = buildDefaultEffectInstance({ effectType });
return {
type: "effect",
name: capitalizeFirstLetter({ string: instance.type }),
effectType,
params: instance.params,
duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
};
}
export function buildStickerElement({
stickerId,
name,
@ -211,6 +243,7 @@ export function buildVideoElement({
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
muted: false,
hidden: false,
transform: { ...DEFAULT_TRANSFORM },
@ -267,6 +300,7 @@ export function buildUploadAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: 1,
muted: false,
};
@ -329,6 +363,7 @@ export function buildLibraryAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: 1,
muted: false,
};

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