From 86a3592c88575d8824aff2b3f0b27eef84700741 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 09:28:54 +0100 Subject: [PATCH 01/24] refactor: make export audio consistent to preview fixes audio export issue with .mkv files --- apps/web/src/lib/media/audio.ts | 68 +++++++++++++++++-- .../src/services/renderer/scene-exporter.ts | 4 +- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts index 0b71cbaa..6f789301 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -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 { + 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 { - const context = audioContext ?? createAudioContext(); + const context = audioContext ?? createAudioContext({ sampleRate }); const audioElements = await collectAudioElements({ tracks, diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts index 39e94e55..a715efe7 100644 --- a/apps/web/src/services/renderer/scene-exporter.ts +++ b/apps/web/src/services/renderer/scene-exporter.ts @@ -13,11 +13,9 @@ import { QUALITY_VERY_HIGH, } from "mediabunny"; import type { RootNode } from "./nodes/root-node"; +import type { ExportFormat, ExportQuality } from "@/types/export"; import { CanvasRenderer } from "./canvas-renderer"; -export type ExportFormat = "mp4" | "webm"; -export type ExportQuality = "low" | "medium" | "high" | "very_high"; - type ExportParams = { width: number; height: number; From dbfecbba59bb37aa73a133987230f6badecb080c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 09:38:38 +0100 Subject: [PATCH 02/24] docker + update readme --- .dockerignore | 15 ++++++ README.md | 104 ++++++++++-------------------------------- apps/web/.env.example | 2 +- apps/web/Dockerfile | 70 ++++++++++++++++++++++++++++ docker-compose.yml | 94 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 82 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/web/Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..25735115 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/README.md b/README.md index 673e38a7..ba3e5604 100644 --- a/README.md +++ b/README.md @@ -38,110 +38,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. diff --git a/apps/web/.env.example b/apps/web/.env.example index b321f09a..e1943571 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -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 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 00000000..9f2dfa2c --- /dev/null +++ b/apps/web/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..ae75391e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,94 @@ +services: + db: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: opencut + POSTGRES_PASSWORD: opencut + POSTGRES_DB: opencut + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U opencut"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + + serverless-redis-http: + image: hiett/serverless-redis-http:latest + ports: + - "8079:80" + environment: + SRH_MODE: env + SRH_TOKEN: example_token + SRH_CONNECTION_STRING: "redis://redis:6379" + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:80 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + + web: + build: + context: . + dockerfile: ./apps/web/Dockerfile + args: + - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} + - FREESOUND_API_KEY=${FREESOUND_API_KEY} + restart: unless-stopped + ports: + - "3100:3000" + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://opencut:opencut@db:5432/opencut + - BETTER_AUTH_SECRET=your-production-secret-key-here + - UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80 + - UPSTASH_REDIS_REST_TOKEN=example_token + - NEXT_PUBLIC_SITE_URL=http://localhost:3100 + - NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com + - MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder} + - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} + - FREESOUND_API_KEY=${FREESOUND_API_KEY} + # Transcription (Optional - leave blank to disable auto-captions) + - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-placeholder} + - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID:-placeholder} + - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY:-placeholder} + - R2_BUCKET_NAME=${R2_BUCKET_NAME:-opencut-transcription} + - MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL:-http://localhost:0} + depends_on: + db: + condition: service_healthy + serverless-redis-http: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + postgres_data: + +networks: + default: + name: opencut-network From baa5f33407da81463196cde7639592d1fc984494 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 10:03:15 +0100 Subject: [PATCH 03/24] fix preview perf issues --- .../editor/panels/preview/index.tsx | 1 + .../src/services/renderer/nodes/image-node.ts | 80 ++++++++++++++----- .../services/renderer/nodes/sticker-node.ts | 53 +++++++----- .../src/services/renderer/scene-builder.ts | 6 ++ 4 files changed, 99 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 90cb11a3..df60db01 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -69,6 +69,7 @@ function RenderTreeController() { duration, canvasSize: { width, height }, background: activeProject.settings.background, + isPreview: true, }); editor.renderer.setRenderTree({ renderTree }); diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 49e84ebe..b3a0be74 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -3,26 +3,71 @@ import { VisualNode, type VisualNodeParams } from "./visual-node"; export interface ImageNodeParams extends VisualNodeParams { url: string; + maxSourceSize?: number; } -export class ImageNode extends VisualNode { - private image?: HTMLImageElement; - private readyPromise: Promise; +interface CachedImageSource { + source: HTMLImageElement | OffscreenCanvas; + width: number; + height: number; +} - constructor(params: ImageNodeParams) { - super(params); - this.readyPromise = this.load(); - } +const imageSourceCache = new Map>(); - private async load() { +function loadImageSource( + url: string, + maxSourceSize?: number, +): Promise { + const cacheKey = `${url}::${maxSourceSize ?? "full"}`; + + const cached = imageSourceCache.get(cacheKey); + if (cached) return cached; + + const promise = (async (): Promise => { const image = new Image(); - this.image = image; await new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error("Image load failed")); - image.src = this.params.url; + image.src = url; }); + + const naturalWidth = image.naturalWidth; + const naturalHeight = image.naturalHeight; + const exceedsLimit = + maxSourceSize && + (naturalWidth > maxSourceSize || naturalHeight > maxSourceSize); + + if (exceedsLimit) { + const scale = Math.min( + maxSourceSize / naturalWidth, + maxSourceSize / naturalHeight, + ); + const scaledWidth = Math.round(naturalWidth * scale); + const scaledHeight = Math.round(naturalHeight * scale); + + const offscreen = new OffscreenCanvas(scaledWidth, scaledHeight); + const ctx = offscreen.getContext("2d"); + + if (ctx) { + ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight); + return { source: offscreen, width: scaledWidth, height: scaledHeight }; + } + } + + return { source: image, width: naturalWidth, height: naturalHeight }; + })(); + + imageSourceCache.set(cacheKey, promise); + return promise; +} + +export class ImageNode extends VisualNode { + private cachedSource: Promise; + + constructor(params: ImageNodeParams) { + super(params); + this.cachedSource = loadImageSource(params.url, params.maxSourceSize); } async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { @@ -32,20 +77,13 @@ export class ImageNode extends VisualNode { return; } - await this.readyPromise; - - if (!this.image) { - return; - } - - const mediaW = this.image.naturalWidth || renderer.width; - const mediaH = this.image.naturalHeight || renderer.height; + const { source, width, height } = await this.cachedSource; this.renderVisual({ renderer, - source: this.image, - sourceWidth: mediaW, - sourceHeight: mediaH, + source, + sourceWidth: width || renderer.width, + sourceHeight: height || renderer.height, }); } } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index a2fb0170..acf638eb 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -6,29 +6,46 @@ export interface StickerNodeParams extends VisualNodeParams { stickerId: string; } -export class StickerNode extends VisualNode { - private image?: HTMLImageElement; - private readyPromise: Promise; +interface CachedStickerSource { + source: HTMLImageElement; + width: number; + height: number; +} - constructor(params: StickerNodeParams) { - super(params); - this.readyPromise = this.load(); - } +const stickerSourceCache = new Map>(); - private async load() { - const image = new Image(); - this.image = image; +function loadStickerSource(stickerId: string): Promise { + const cached = stickerSourceCache.get(stickerId); + if (cached) return cached; + + const promise = (async (): Promise => { const url = resolveStickerId({ - stickerId: this.params.stickerId, + stickerId, options: { width: 200, height: 200 }, }); + const image = new Image(); + await new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => - reject(new Error(`Failed to load sticker: ${this.params.stickerId}`)); + reject(new Error(`Failed to load sticker: ${stickerId}`)); image.src = url; }); + + return { source: image, width: 200, height: 200 }; + })(); + + stickerSourceCache.set(stickerId, promise); + return promise; +} + +export class StickerNode extends VisualNode { + private cachedSource: Promise; + + constructor(params: StickerNodeParams) { + super(params); + this.cachedSource = loadStickerSource(params.stickerId); } async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { @@ -38,17 +55,13 @@ export class StickerNode extends VisualNode { return; } - await this.readyPromise; - - if (!this.image) { - return; - } + const { source, width, height } = await this.cachedSource; this.renderVisual({ renderer, - source: this.image, - sourceWidth: 200, - sourceHeight: 200, + source, + sourceWidth: width, + sourceHeight: height, }); } } diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index ee7f3d6c..530b2eb3 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -11,12 +11,15 @@ import type { TBackground, TCanvasSize } from "@/types/project"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { isMainTrack } from "@/lib/timeline"; +const PREVIEW_MAX_IMAGE_SIZE = 2048; + export type BuildSceneParams = { canvasSize: TCanvasSize; tracks: TimelineTrack[]; mediaAssets: MediaAsset[]; duration: number; background: TBackground; + isPreview?: boolean; }; export function buildScene(params: BuildSceneParams) { @@ -81,6 +84,9 @@ export function buildScene(params: BuildSceneParams) { transform: element.transform, opacity: element.opacity, blendMode: element.blendMode, + ...(params.isPreview && { + maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, + }), }), ); } From d352e585709bf4011c78719e020592f345401195 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 10:15:51 +0100 Subject: [PATCH 04/24] fix preview snapping threshold --- apps/web/src/hooks/use-preview-interaction.ts | 16 +++++++++-- apps/web/src/hooks/use-transform-handles.ts | 11 +++++++- apps/web/src/lib/preview/preview-coords.ts | 14 ++++++++++ apps/web/src/lib/preview/preview-snap.ts | 28 +++++++++++++------ 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 2bb33f21..d29f2a4b 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -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, diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index 37ef6d5f..8be6420c 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -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[] }; diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts index 08c16735..dcc87282 100644 --- a/apps/web/src/lib/preview/preview-coords.ts +++ b/apps/web/src/lib/preview/preview-coords.ts @@ -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), + }; +} diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts index bd53f0ca..36efc3ce 100644 --- a/apps/web/src/lib/preview/preview-snap.ts +++ b/apps/web/src/lib/preview/preview-snap.ts @@ -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({ From ab64c48c92f13434f988628a2e5ecf53063d0949 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 10:55:06 +0100 Subject: [PATCH 05/24] feat: make playhead snap to elements --- .../hooks/timeline/use-timeline-playhead.ts | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 44e12ab1..1e536dae 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -3,10 +3,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; 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"; +import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; interface UseTimelinePlayheadProps { @@ -31,8 +28,8 @@ export function useTimelinePlayhead({ const isPlaying = editor.playback.getIsPlaying(); const isScrubbing = editor.playback.getIsScrubbing(); const isShiftHeldRef = useShiftKey(); - const { snapToNearestPoint } = useTimelineSnapping({ - enableElementSnapping: false, + const { snapToNearestPoint, findSnapPoints } = useTimelineSnapping({ + enableElementSnapping: true, enablePlayheadSnapping: false, }); @@ -80,21 +77,24 @@ 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 = !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, + }); + const snapResult = snapToNearestPoint({ + targetTime: frameTime, + snapPoints, + zoomLevel, + }); + return snapResult.snapPoint ? snapResult.snappedTime : frameTime; + })(); setScrubTime(time); seek({ time }); @@ -109,6 +109,8 @@ export function useTimelinePlayhead({ activeProject.settings.fps, isShiftHeldRef, editor.scenes, + editor.timeline, + findSnapPoints, snapToNearestPoint, ], ); From 4d77e3f2bb9884ad9e43761eaf0ede8c1a9cfccc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 10:55:35 +0100 Subject: [PATCH 06/24] refactor: snapping system --- .../editor/panels/timeline/index.tsx | 2 +- .../editor/panels/timeline/snap-indicator.tsx | 2 +- .../panels/timeline/timeline-element.tsx | 2 +- .../editor/panels/timeline/timeline-track.tsx | 2 +- .../element/use-element-interaction.ts | 16 +- .../timeline/element/use-element-resize.ts | 9 +- .../src/hooks/timeline/use-bookmark-drag.ts | 20 +- .../hooks/timeline/use-timeline-playhead.ts | 12 +- .../hooks/timeline/use-timeline-snapping.ts | 185 ------------------ apps/web/src/lib/timeline/snap-utils.ts | 155 +++++++++++++++ 10 files changed, 179 insertions(+), 226 deletions(-) delete mode 100644 apps/web/src/hooks/timeline/use-timeline-snapping.ts create mode 100644 apps/web/src/lib/timeline/snap-utils.ts diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 7e6ff326..18f768a9 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -23,7 +23,7 @@ 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, diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index e70f0cd7..54ce5237 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -1,7 +1,7 @@ "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 type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 628855f6..657e9a8b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,7 +4,7 @@ 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 type { SnapPoint } from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { getTrackClasses, diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index b7b06d5b..42faadaf 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -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"; diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 79f1272c..e7b93813 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -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(() => { diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 14693fff..2b303ad9 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -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(null); const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); @@ -269,8 +270,6 @@ export function useTimelineElementResize({ activeProject.settings.fps, snappingEnabled, editor, - findSnapPoints, - snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration, diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index faf28e40..0e7fabd5 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -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({ 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(() => { diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 1e536dae..d6bb29e2 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -3,7 +3,10 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEditor } from "../use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import { + findSnapPoints, + snapToNearestPoint, +} from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; interface UseTimelinePlayheadProps { @@ -28,10 +31,6 @@ export function useTimelinePlayhead({ const isPlaying = editor.playback.getIsPlaying(); const isScrubbing = editor.playback.getIsScrubbing(); const isShiftHeldRef = useShiftKey(); - const { snapToNearestPoint, findSnapPoints } = useTimelineSnapping({ - enableElementSnapping: true, - enablePlayheadSnapping: false, - }); const seek = useCallback( ({ time }: { time: number }) => editor.playback.seek({ time }), @@ -87,6 +86,7 @@ export function useTimelinePlayhead({ tracks, playheadTime: frameTime, bookmarks, + enablePlayheadSnapping: false, }); const snapResult = snapToNearestPoint({ targetTime: frameTime, @@ -110,8 +110,6 @@ export function useTimelinePlayhead({ isShiftHeldRef, editor.scenes, editor.timeline, - findSnapPoints, - snapToNearestPoint, ], ); diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts deleted file mode 100644 index 5de75448..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts +++ /dev/null @@ -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; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array; - 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; - 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; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array; - }): 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, - }; -} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts new file mode 100644 index 00000000..a29737d0 --- /dev/null +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -0,0 +1,155 @@ +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; +} + +const DEFAULT_SNAP_THRESHOLD_PX = 10; + +export function findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + bookmarks = [], + excludeBookmarkTime, + enableElementSnapping = true, + enablePlayheadSnapping = true, + enableBookmarkSnapping = true, +}: { + tracks: Array; + playheadTime: number; + excludeElementId?: string; + bookmarks?: Array; + excludeBookmarkTime?: number; + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; + enableBookmarkSnapping?: boolean; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + + if (enableElementSnapping) { + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + snapPoints.push( + { + time: element.startTime, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: element.startTime + element.duration, + 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; +} + +export function snapToNearestPoint({ + targetTime, + snapPoints, + zoomLevel, + snapThreshold = DEFAULT_SNAP_THRESHOLD_PX, +}: { + targetTime: number; + snapPoints: Array; + zoomLevel: number; + snapThreshold?: 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, + }; +} + +export function snapElementEdge({ + targetTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId, + snapToStart = true, + bookmarks = [], +}: { + targetTime: number; + elementDuration: number; + tracks: Array; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + bookmarks?: Array; +}): 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; +} From f75b5d3fd28349ee69f03be34c1316122992c283 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Feb 2026 01:37:56 +0100 Subject: [PATCH 07/24] fix a ton of issues + improvement --- .../panels/preview/text-edit-overlay.tsx | 4 +- .../editor/panels/properties/index.tsx | 2 +- .../editor/panels/properties/section.tsx | 28 + .../panels/properties/sections/blending.tsx | 132 ++-- .../panels/properties/sections/transform.tsx | 186 +++--- .../panels/properties/text-properties.tsx | 570 ++++++++++++++---- apps/web/src/components/ui/color-picker.tsx | 20 +- apps/web/src/components/ui/font-picker.tsx | 4 +- apps/web/src/components/ui/number-field.tsx | 7 +- apps/web/src/constants/text-constants.ts | 11 +- .../hooks/timeline/use-timeline-playhead.ts | 20 +- apps/web/src/lib/preview/element-bounds.ts | 53 +- apps/web/src/lib/text/layout.ts | 167 +++++ apps/web/src/lib/timeline/element-utils.ts | 9 +- .../src/services/renderer/nodes/text-node.ts | 112 ++-- .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v6-to-v7.ts | 106 ++++ .../services/storage/migrations/v6-to-v7.ts | 16 + apps/web/src/types/timeline.ts | 9 +- packages/ui/src/icons/ui.tsx | 126 ++-- 20 files changed, 1134 insertions(+), 452 deletions(-) create mode 100644 apps/web/src/lib/text/layout.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts create mode 100644 apps/web/src/services/storage/migrations/v6-to-v7.ts diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index 265e972d..fdd4055d 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -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 (
{selectedElements.length > 0 ? ( - + {elementsWithTracks.map(({ track, element }) => { if (element.type === "text") { return ( diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx index 0b45065f..72d78d80 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -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(); @@ -131,6 +132,33 @@ export function SectionHeader({ return
{content}
; } +export function SectionFields({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} + +export function SectionField({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ + {children} +
+ ); +} + export function SectionContent({ children, className, diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx index 430efa25..4343c692 100644 --- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -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 (
- -
-
- +
+ + + } + 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" + /> + + + - } - className="w-full" - > - - - - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - - {group.map((option) => ( - - previewBlendMode(option.value as BlendMode) - } - > - {option.label} - - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - - ) : null} - - ))} - - -
-
- + + + + {BLEND_MODE_GROUPS.map((group, groupIndex) => ( + + {group.map((option) => ( + + previewBlendMode(option.value as BlendMode) + } + > + {option.label} + + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + + ) : null} + + ))} + + + +
+
); } diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx index 17a54b4c..21da36e9 100644 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -3,7 +3,7 @@ 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 { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -120,8 +120,9 @@ export function TransformSection({ return (
- -
+ + +
{isScaleLocked ? ( <> @@ -144,105 +145,108 @@ export function TransformSection({
+
+
- - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - position: { - ...element.transform.position, - x: DEFAULT_TRANSFORM.position.x, + + 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 - } - /> - - 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.x === DEFAULT_TRANSFORM.position.x + } + /> + + 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 - } - /> + ], + }) + } + isDefault={ + element.transform.position.y === DEFAULT_TRANSFORM.position.y + } + />
+
-
- } - 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, - }, + + } + className="flex-none" + 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} - /> -
-
-
+ }, + ], + }) + } + isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} + /> + + +
); } diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index cfca52dc..1a17c26a 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -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({ - - + + +
); } @@ -83,7 +102,7 @@ function ContentSection({ ); } -function FontSection({ +function TypographySection({ element, trackId, }: { @@ -109,10 +128,11 @@ function FontSection({ }); return ( -
- - -
+
+ + + + @@ -127,89 +147,33 @@ function FontSection({ }) } /> - - - -
- - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - }) - } - onValueCommit={() => editor.timeline.commitPreview()} - className="w-full" - /> - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: DEFAULT_TEXT_ELEMENT.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} - /> -
-
-
-
- ); -} - -function ColorSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_COLOR); - - return ( -
- - -
+ + + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + icon={} + /> + + editor.timeline.commitPreview()} /> - { - 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" - : "" + + + +
+ ); +} + +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 ( +
+ + +
+ + + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }], + }) } + isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING} + icon={} /> -
+ + + + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }], + }) + } + isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT} + icon={} + /> + + +
+
+ ); +} + +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 ( +
+ + + + + { + 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" + : "" + } + /> + +
+ + + 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 + } + /> + + + + 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 + } + /> + +
+
+ + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, offsetX: 0 }, + }, + }, + ], + }) + } + isDefault={(element.background.offsetX ?? 0) === 0} + /> + + + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, offsetY: 0 }, + }, + }, + ], + }) + } + isDefault={(element.background.offsetY ?? 0) === 0} + /> + +
+ + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + cornerRadius: 0, + }, + }, + }, + ], + }) + } + isDefault={(element.background.cornerRadius ?? 0) === 0} + /> + +
); diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index d58b6144..fbf9bbd7 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -280,13 +280,21 @@ const ColorPicker = forwardRef( )} {...props} > - - +
- + {defaultValue ?? "Select a font"} diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index 35cce81b..48297dc0 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -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); }; diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts index f0b2143d..6b688ed1 100644 --- a/apps/web/src/constants/text-constants.ts +++ b/apps/web/src/constants/text-constants.ts @@ -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 = { type: "text", name: "Text", @@ -24,7 +33,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit = { fontSize: 15, fontFamily: "Arial", color: "#ffffff", - backgroundColor: "#000000", + background: DEFAULT_TEXT_BACKGROUND, textAlign: "center", fontWeight: "normal", fontStyle: "normal", diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index d6bb29e2..1426a003 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -47,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(); @@ -76,7 +82,7 @@ export function useTimelinePlayhead({ fps: framesPerSecond, }); - const shouldSnap = !isShiftHeldRef.current; + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const time = (() => { if (!shouldSnap) return frameTime; const tracks = editor.timeline.getTracks(); @@ -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); } diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index ec26c14d..9e58ec52 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -2,9 +2,11 @@ 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"; export interface ElementBounds { cx: number; @@ -121,27 +123,36 @@ 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 * element.transform.scale; + const scaledCenterY = localCenterY * element.transform.scale; + const rotationRad = (element.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 + element.transform.position.x + rotatedCenterX, + cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY, + width: measuredWidth * element.transform.scale, + height: measuredHeight * element.transform.scale, + rotation: element.transform.rotate, + }; } const width = measuredWidth * element.transform.scale; diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts new file mode 100644 index 00000000..8c38e9aa --- /dev/null +++ b/apps/web/src/lib/text/layout.ts @@ -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, + }; +} diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index fa74b14e..c51667bf 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -154,7 +154,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, diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index b7e36d64..76d78b31 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -2,9 +2,16 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/types/timeline"; import { + DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; +import { + getMetricAscent, + getMetricDescent, + getTextBackgroundRect, + measureTextBlock, +} from "@/lib/text/layout"; function scaleFontSize({ fontSize, @@ -20,65 +27,6 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { return `"${fontFamily.replace(/"/g, '\\"')}"`; } -function getMetricAscent({ - metrics, - fallback, -}: { - metrics: TextMetrics; - fallback: number; -}): number { - return metrics.actualBoundingBoxAscent ?? fallback * 0.8; -} - -function getMetricDescent({ - metrics, - fallback, -}: { - metrics: TextMetrics; - fallback: number; -}): number { - return metrics.actualBoundingBoxDescent ?? fallback * 0.2; -} - -interface TextBlockMeasurement { - visualCenterOffset: number; - height: number; - maxWidth: number; -} - -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 i = 0; i < lineMetrics.length; i++) { - const metrics = lineMetrics[i]; - const y = i * lineHeightPx; - top = Math.min( - top, - y - getMetricAscent({ metrics, fallback: fallbackFontSize }), - ); - bottom = Math.max( - bottom, - y + getMetricDescent({ metrics, fallback: fallbackFontSize }), - ); - maxWidth = Math.max(maxWidth, metrics.width); - } - - const height = bottom - top; - const visualCenterOffset = (top + bottom) / 2; - - return { visualCenterOffset, height, maxWidth }; -} - function drawTextDecoration({ ctx, textDecoration, @@ -99,8 +47,8 @@ function drawTextDecoration({ if (textDecoration === "none" || !textDecoration) return; const thickness = Math.max(1, scaledFontSize * 0.07); - const ascent = getMetricAscent({ metrics, fallback: scaledFontSize }); - const descent = getMetricDescent({ metrics, fallback: scaledFontSize }); + const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); + const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize }); let xStart = -lineWidth / 2; if (textAlign === "left") xStart = 0; @@ -171,6 +119,7 @@ export class TextNode extends BaseNode { const lines = this.params.content.split("\n"); const lineHeightPx = scaledFontSize * lineHeight; + const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; const baseline = this.params.textBaseline ?? "middle"; renderer.context.textBaseline = baseline; @@ -191,22 +140,31 @@ export class TextNode extends BaseNode { ) as GlobalCompositeOperation; renderer.context.globalAlpha = this.params.opacity; - if (this.params.backgroundColor && lineCount > 0) { - const padX = 8; - const padY = 4; - renderer.context.fillStyle = this.params.backgroundColor; - let bgLeft = -block.maxWidth / 2; - if (renderer.context.textAlign === "left") bgLeft = 0; - if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth; - - renderer.context.fillRect( - bgLeft - padX, - -block.height / 2 - padY, - block.maxWidth + padX * 2, - block.height + padY * 2, - ); - - renderer.context.fillStyle = this.params.color; + if ( + this.params.background.color && + this.params.background.color !== "transparent" && + lineCount > 0 + ) { + const { color, cornerRadius = 0 } = this.params.background; + const backgroundRect = getTextBackgroundRect({ + textAlign: this.params.textAlign, + block, + background: this.params.background, + fontSizeRatio, + }); + if (backgroundRect) { + renderer.context.fillStyle = color; + renderer.context.beginPath(); + renderer.context.roundRect( + backgroundRect.left, + backgroundRect.top, + backgroundRect.width, + backgroundRect.height, + cornerRadius, + ); + renderer.context.fill(); + renderer.context.fillStyle = this.params.color; + } } for (let i = 0; i < lineCount; i++) { diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 00c009c7..fd40f14c 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -5,10 +5,11 @@ import { V2toV3Migration } from "./v2-to-v3"; import { V3toV4Migration } from "./v3-to-v4"; import { V4toV5Migration } from "./v4-to-v5"; import { V5toV6Migration } from "./v5-to-v6"; +import { V6toV7Migration } from "./v6-to-v7"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 6; +export const CURRENT_PROJECT_VERSION = 7; export const migrations = [ new V0toV1Migration(), @@ -17,4 +18,5 @@ export const migrations = [ new V3toV4Migration(), new V4toV5Migration(), new V5toV6Migration(), + new V6toV7Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts new file mode 100644 index 00000000..6f4848d4 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts @@ -0,0 +1,106 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV6ToV7({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV7Project({ project })) { + return { project, skipped: true, reason: "already v7" }; + } + + const migratedProject = migrateProjectTextElements({ project }); + + return { + project: { ...migratedProject, version: 7 }, + skipped: false, + }; +} + +function migrateProjectTextElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneTextElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneTextElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackTextElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackTextElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateTextElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateTextElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "text") return element; + if (isRecord(element.background)) return element; + + const backgroundColor = + typeof element.backgroundColor === "string" + ? element.backgroundColor + : "transparent"; + + const { backgroundColor: _removed, ...rest } = element; + + return { + ...rest, + background: { + color: backgroundColor, + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, + }; +} + +function isV7Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 7; +} diff --git a/apps/web/src/services/storage/migrations/v6-to-v7.ts b/apps/web/src/services/storage/migrations/v6-to-v7.ts new file mode 100644 index 00000000..0556b4d5 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v6-to-v7.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV6ToV7 } from "./transformers/v6-to-v7"; + +export class V6toV7Migration extends StorageMigration { + from = 6; + to = 7; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV6ToV7({ project }); + } +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index da96c7de..a4c40490 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -114,7 +114,14 @@ export interface TextElement extends BaseTimelineElement { fontSize: number; fontFamily: string; color: string; - backgroundColor: string; + background: { + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; + }; textAlign: "left" | "center" | "right"; fontWeight: "normal" | "bold"; fontStyle: "normal" | "italic"; diff --git a/packages/ui/src/icons/ui.tsx b/packages/ui/src/icons/ui.tsx index 382ec7bb..e9d4cb39 100644 --- a/packages/ui/src/icons/ui.tsx +++ b/packages/ui/src/icons/ui.tsx @@ -65,51 +65,6 @@ export function OcCheckerboardIcon({ ); } -export function OcFontWeightIcon({ - className = "", - size = 32, -}: { - className?: string; - size?: number; -}) { - return ( - - Font Weight - - - - - ); -} - export function OcSlidersVerticalIcon({ className = "", size = 32, @@ -203,3 +158,84 @@ export function OcSocialIcon({ ); } + +export function OcTextWidthIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + + Text width + + + ); +} + +export function OcTextHeightIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + + Text height + + + ); +} + +export function OcFontIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + + Font + + + ); +} From f96192289b4538d8efc07ccd39ffca00e1acc7bc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Feb 2026 01:40:12 +0100 Subject: [PATCH 08/24] docs: move sponsors near the top --- README.md | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ba3e5604..cacc4555 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ +## 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. + + + Vercel OSS Program + + + + Powered by fal.ai + + ## Why? - **Privacy**: Your videos stay on your device @@ -100,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. - - - Vercel OSS Program - - - - Powered by fal.ai - - ---- - -[![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) From 6da55c7aeeacb4d6e635858af6dabf83168bb542 Mon Sep 17 00:00:00 2001 From: Dipanshu Rawat Date: Wed, 25 Feb 2026 07:57:27 +0530 Subject: [PATCH 09/24] chore: improve accessibility and security (#707) - Update roadmap description date from July 14, 2025 to February 2026 - Improve landing page hero image alt text for better a11y - Add noreferrer to external links for security best practice --- apps/web/src/app/privacy/page.tsx | 2 +- apps/web/src/app/roadmap/page.tsx | 2 +- apps/web/src/components/landing/hero.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index b71b8d30..4b06405a 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -166,7 +166,7 @@ export default function PrivacyPage() { Databuddy diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index 5227f808..88457f7c 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -88,7 +88,7 @@ export default function RoadmapPage() { return (
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 6f11ad7c..a8f3f70d 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -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" />
From 987153266ec44c201f6d328d2e18148b06318f6b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Feb 2026 04:14:21 +0100 Subject: [PATCH 10/24] refactor: move transform type to more relevant file --- apps/web/src/types/rendering.ts | 9 +++++++++ apps/web/src/types/timeline.ts | 11 ++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/src/types/rendering.ts b/apps/web/src/types/rendering.ts index f5e18502..8c9763df 100644 --- a/apps/web/src/types/rendering.ts +++ b/apps/web/src/types/rendering.ts @@ -1,3 +1,12 @@ +export interface Transform { + scale: number; + position: { + x: number; + y: number; + }; + rotate: number; +} + export type BlendMode = | "normal" | "darken" diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index a4c40490..5a98bd31 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,4 +1,4 @@ -import type { BlendMode } from "./rendering"; +import type { BlendMode, Transform } from "./rendering"; export interface Bookmark { time: number; @@ -52,14 +52,7 @@ export interface StickerTrack extends BaseTrack { export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack; -export interface Transform { - scale: number; - position: { - x: number; - y: number; - }; - rotate: number; -} +export type { Transform } from "./rendering"; interface BaseAudioElement extends BaseTimelineElement { type: "audio"; From 07c22891687c6c41321d6119e7ddf3740fa72b8b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Feb 2026 04:22:00 +0100 Subject: [PATCH 11/24] feat: add properties for video/image/sticker elements --- .../editor/panels/properties/index.tsx | 10 +++++++--- .../panels/properties/video-properties.tsx | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 790acdda..4f285f84 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -17,7 +17,7 @@ export function PropertiesPanel() { }); return ( -
+
{selectedElements.length > 0 ? ( {elementsWithTracks.map(({ track, element }) => { @@ -31,10 +31,14 @@ export function PropertiesPanel() { if (element.type === "audio") { return ; } - if (element.type === "video" || element.type === "image") { + if ( + element.type === "video" || + element.type === "image" || + element.type === "sticker" + ) { return (
- +
); } diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx index bf6d92d8..3588cbb7 100644 --- a/apps/web/src/components/editor/panels/properties/video-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx @@ -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
Video properties
; + return ( +
+ + +
+ ); } From 9b94f89def4f96836c4f42a23a73e97fe647a43c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Feb 2026 04:51:50 +0100 Subject: [PATCH 12/24] roadmao --- apps/web/src/app/roadmap/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index 88457f7c..77d9a0cc 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -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 (
From 49426f19cd8eaed7cf6c47999b66fee7ee637a56 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Feb 2026 16:33:57 +0100 Subject: [PATCH 13/24] feat: implement keyframe animation system Add keyframe support for transform, opacity, and volume properties. Includes animation engine (interpolation, mutations, resolvers), timeline markers with selection/snapping, properties panel toggles, keyframe-aware renderer, and full undo/redo command support. Also refactor element command constructors to object params, extract timeline pixel math to pixel-utils.ts, and update cursor rules. --- .cursor/commands/review.md | 3 +- .cursor/rules/ultracite.mdc | 1 - .../hooks/use-keyframed-number-property.ts | 151 +++++ .../panels/properties/keyframe-toggle.tsx | 32 + .../editor/panels/properties/section.tsx | 11 +- .../panels/properties/sections/transform.tsx | 457 +++++++++----- .../editor/panels/timeline/bookmarks.tsx | 13 +- .../editor/panels/timeline/snap-indicator.tsx | 8 +- .../panels/timeline/timeline-element.tsx | 215 ++++++- .../panels/timeline/timeline-playhead.tsx | 16 +- .../editor/panels/timeline/timeline-tick.tsx | 4 +- apps/web/src/constants/animation-constants.ts | 2 + .../src/core/managers/selection-manager.ts | 37 ++ .../web/src/core/managers/timeline-manager.ts | 138 +++- .../element/use-element-interaction.ts | 5 +- .../element/use-keyframe-selection.ts | 229 +++++++ .../timeline/use-snap-indicator-position.ts | 8 +- .../__tests__/transform-keyframes.test.ts | 313 +++++++++ apps/web/src/lib/animation/index.ts | 39 ++ apps/web/src/lib/animation/interpolation.ts | 365 +++++++++++ apps/web/src/lib/animation/keyframe-query.ts | 67 ++ apps/web/src/lib/animation/keyframes.ts | 593 ++++++++++++++++++ apps/web/src/lib/animation/number-channel.ts | 20 + .../src/lib/animation/property-registry.ts | 230 +++++++ apps/web/src/lib/animation/resolve.ts | 111 ++++ .../__tests__/keyframe-aware-commands.test.ts | 433 +++++++++++++ .../lib/commands/timeline/clipboard/paste.ts | 5 + .../timeline/element/delete-elements.ts | 14 +- .../timeline/element/duplicate-elements.ts | 14 +- .../lib/commands/timeline/element/index.ts | 1 + .../timeline/element/keyframes/index.ts | 3 + .../element/keyframes/remove-keyframe.ts | 141 +++++ .../element/keyframes/retime-keyframe.ts | 75 +++ .../element/keyframes/upsert-keyframe.ts | 89 +++ .../timeline/element/move-elements.ts | 45 +- .../timeline/element/split-elements.ts | 34 +- .../element/update-element-duration.ts | 40 +- .../element/update-element-start-time.ts | 21 +- .../timeline/element/update-element-trim.ts | 38 +- .../timeline/element/update-element.ts | 34 +- apps/web/src/lib/preview/element-bounds.ts | 54 +- apps/web/src/lib/timeline/element-utils.ts | 6 +- apps/web/src/lib/timeline/index.ts | 2 + apps/web/src/lib/timeline/pixel-utils.ts | 79 +++ apps/web/src/lib/timeline/snap-utils.ts | 27 +- .../src/lib/timeline/track-element-update.ts | 33 + .../src/services/renderer/nodes/image-node.ts | 1 + .../services/renderer/nodes/sticker-node.ts | 1 + .../src/services/renderer/nodes/text-node.ts | 35 +- .../src/services/renderer/nodes/video-node.ts | 3 +- .../services/renderer/nodes/visual-node.ts | 38 +- .../src/services/renderer/scene-builder.ts | 3 + apps/web/src/types/animation.ts | 89 +++ apps/web/src/types/timeline.ts | 13 + apps/web/src/utils/math.ts | 12 + 55 files changed, 4139 insertions(+), 312 deletions(-) create mode 100644 apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts create mode 100644 apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx create mode 100644 apps/web/src/constants/animation-constants.ts create mode 100644 apps/web/src/hooks/timeline/element/use-keyframe-selection.ts create mode 100644 apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts create mode 100644 apps/web/src/lib/animation/index.ts create mode 100644 apps/web/src/lib/animation/interpolation.ts create mode 100644 apps/web/src/lib/animation/keyframe-query.ts create mode 100644 apps/web/src/lib/animation/keyframes.ts create mode 100644 apps/web/src/lib/animation/number-channel.ts create mode 100644 apps/web/src/lib/animation/property-registry.ts create mode 100644 apps/web/src/lib/animation/resolve.ts create mode 100644 apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/index.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts create mode 100644 apps/web/src/lib/timeline/pixel-utils.ts create mode 100644 apps/web/src/lib/timeline/track-element-update.ts create mode 100644 apps/web/src/types/animation.ts diff --git a/.cursor/commands/review.md b/.cursor/commands/review.md index ab36f7c4..a2071ce8 100644 --- a/.cursor/commands/review.md +++ b/.cursor/commands/review.md @@ -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 @@ -134,7 +135,7 @@ Do NOT review by reading the file top-to-bottom and noting what jumps out. Inste 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 ✓` +`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | File Names ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓` --- diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc index 977f4363..3dcaabaa 100644 --- a/.cursor/rules/ultracite.mdc +++ b/.cursor/rules/ultracite.mdc @@ -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`. diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts new file mode 100644 index 00000000..cd62ca75 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -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, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx new file mode 100644 index 00000000..b5b8685f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx index 72d78d80..3edd17b5 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -139,21 +139,28 @@ export function SectionFields({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return ( +
{children}
+ ); } export function SectionField({ label, + beforeLabel, children, className, }: { label: string; + beforeLabel?: React.ReactNode; children: React.ReactNode; className?: string; }) { return (
- +
+ {beforeLabel} + +
{children}
); diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx index 21da36e9..3fdb47dc 100644 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -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, SectionField, SectionFields, 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) => { - 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,66 +148,148 @@ 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 (
- - - -
- {isScaleLocked ? ( - <> - - - - ) : ( - } - {...scaleFieldProps} - className="flex-1" + + + - )} - -
-
- -
+ } + > +
+ {isScaleLocked ? ( + <> + + + + ) : ( + } + {...scaleFieldProps} + className="flex-1" + /> + )} + +
+ + + } + > +
- 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 + 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, + })} /> - 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 + 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, + })} /> -
-
+
+
- - } - className="flex-none" - 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} - /> - -
-
+ > +
+ } + 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, + })} + /> +
+ + +
); } diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 64824f9a..864bb557 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -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; diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 54ce5237..927d336a 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -2,6 +2,10 @@ import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; 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({
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 657e9a8b..7ad63692 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -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 { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +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,123 @@ import { VolumeMute02Icon, Search01Icon, Exchange01Icon, + KeyframeIcon, } 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[]; + isSelected: boolean; +} + +function buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + isKeyframeSelected, +}: { + keyframe: ElementKeyframe; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + isKeyframeSelected: ({ + keyframe, + }: { + keyframe: SelectedKeyframeRef; + }) => boolean; +}): { + time: number; + offsetPx: number; + keyframeRef: SelectedKeyframeRef; + isSelected: boolean; +} { + 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, + isSelected: isKeyframeSelected({ keyframe: keyframeRef }), + }; +} + +function getKeyframeIndicators({ + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + isKeyframeSelected, +}: { + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + elementWidth: number; + isKeyframeSelected: ({ + keyframe, + }: { + keyframe: SelectedKeyframeRef; + }) => boolean; +}): KeyframeIndicator[] { + if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { + return []; + } + + const keyframesByTime = new Map(); + for (const keyframe of keyframes) { + const indicator = buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + isKeyframeSelected, + }); + const existingIndicator = keyframesByTime.get(indicator.time); + if (!existingIndicator) { + keyframesByTime.set(indicator.time, { + time: indicator.time, + offsetPx: indicator.offsetPx, + keyframes: [indicator.keyframeRef], + isSelected: indicator.isSelected, + }); + continue; + } + + existingIndicator.keyframes.push(indicator.keyframeRef); + existingIndicator.isSelected = + existingIndicator.isSelected || indicator.isSelected; + } + + return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); +} function getDisplayShortcut(action: TAction) { const { defaultShortcuts } = getActionDefinition(action); @@ -86,6 +202,7 @@ export function TimelineElement({ }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); + const { isKeyframeSelected } = useKeyframeSelection(); const { requestRevealMedia } = useAssetsPanelStore(); const mediaAssets = editor.media.getAssets(); @@ -123,10 +240,24 @@ 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 = getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + isKeyframeSelected, + }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -161,6 +292,7 @@ export function TimelineElement({ onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} /> +
@@ -197,7 +329,9 @@ export function TimelineElement({ <> } - onClick={(event) => handleRevealInMedia({ event })} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } > Reveal media @@ -271,7 +405,6 @@ function ElementInner({ mediaAssets={mediaAssets} />
- {(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && ( @@ -335,6 +468,67 @@ function ResizeHandle({ ); } +function KeyframeIndicators({ + indicators, +}: { + indicators: KeyframeIndicator[]; +}) { + const { 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) => ( + + )); +} + function ElementContent({ element, track, @@ -549,10 +743,11 @@ function ActionMenuItem({ ...props }: Omit, "onClick" | "textRight"> & { action: TAction; + children: ReactNode; }) { return ( { + onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction(action); }} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index ebb2a37e..e290259e 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -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, @@ -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} > diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 9402d25e..22cd52cf 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -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 }); diff --git a/apps/web/src/constants/animation-constants.ts b/apps/web/src/constants/animation-constants.ts new file mode 100644 index 00000000..44776d34 --- /dev/null +++ b/apps/web/src/constants/animation-constants.ts @@ -0,0 +1,2 @@ +export const TIME_EPSILON_SECONDS = 1 / 1000; +export const MIN_TRANSFORM_SCALE = 0.01; diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts index fa6a02bf..e4295c11 100644 --- a/apps/web/src/core/managers/selection-manager.ts +++ b/apps/web/src/core/managers/selection-manager.ts @@ -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(); } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index c8ac5a79..11c976a8 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -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 }); } @@ -145,7 +160,11 @@ export class TimelineManager { splitTime: number; retainSide?: "both" | "left" | "right"; }): { trackId: string; elementId: string }[] { - const command = new SplitElementsCommand(elements, splitTime, retainSide); + const command = new SplitElementsCommand({ + elements, + splitTime, + retainSide, + }); this.editor.command.execute({ command }); return command.getRightSideElements(); } @@ -197,7 +216,7 @@ export class TimelineManager { }: { elements: { trackId: string; elementId: string }[]; }): void { - const command = new DeleteElementsCommand(elements); + const command = new DeleteElementsCommand({ elements }); this.editor.command.execute({ command }); } @@ -208,13 +227,17 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial>; + updates: Partial; }>; 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); @@ -225,6 +248,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 = "linear", + 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(); } @@ -235,7 +351,7 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial>; + updates: Partial; }>; }): void { const tracks = this.getTracks(); diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index e7b93813..3831a86f 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -592,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 { diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts new file mode 100644 index 00000000..50def392 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts @@ -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(); + 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, + }; +} diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts index d78eb04d..f3e63894 100644 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -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 { diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts new file mode 100644 index 00000000..629bd0d6 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts new file mode 100644 index 00000000..c41c1a7e --- /dev/null +++ b/apps/web/src/lib/animation/index.ts @@ -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"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts new file mode 100644 index 00000000..a8f60643 --- /dev/null +++ b/apps/web/src/lib/animation/interpolation.ts @@ -0,0 +1,365 @@ +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({ + channel, +}: { + channel: TChannel; +}): TChannel { + return { + ...channel, + keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => + byTimeAscending({ + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }), + ), + } as TChannel; +} + +function evaluateChannelValueAtTime({ + 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]; + 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, + }); +} diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts new file mode 100644 index 00000000..f3288e7a --- /dev/null +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -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( + (kf) => Math.abs(kf.time - time) <= TIME_EPSILON_SECONDS, + ); + if (!keyframe) return null; + return { + propertyPath, + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + }; +} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts new file mode 100644 index 00000000..56f156a4 --- /dev/null +++ b/apps/web/src/lib/animation/keyframes.ts @@ -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, + }); +} diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts new file mode 100644 index 00000000..67166156 --- /dev/null +++ b/apps/web/src/lib/animation/number-channel.ts @@ -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; +} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts new file mode 100644 index 00000000..44f5d638 --- /dev/null +++ b/apps/web/src/lib/animation/property-registry.ts @@ -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; +} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts new file mode 100644 index 00000000..58f292b1 --- /dev/null +++ b/apps/web/src/lib/animation/resolve.ts @@ -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, + }); +} diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts new file mode 100644 index 00000000..d2356a39 --- /dev/null +++ b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 49554e0e..800cbb90 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -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); } diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 91eb6397..3d1e50cc 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -5,9 +5,15 @@ import { isMainTrack } from "@/lib/timeline"; export class DeleteElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; + private readonly elements: { trackId: string; elementId: string }[]; - constructor(private elements: { trackId: string; elementId: string }[]) { + constructor({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }) { super(); + this.elements = elements; } execute(): void { @@ -17,7 +23,7 @@ export class DeleteElementsCommand extends Command { const updatedTracks = this.savedState .map((track) => { const hasElementsToDelete = this.elements.some( - (el) => el.trackId === track.id, + (elementEntry) => elementEntry.trackId === track.id, ); if (!hasElementsToDelete) { @@ -29,7 +35,9 @@ export class DeleteElementsCommand extends Command { elements: track.elements.filter( (element) => !this.elements.some( - (el) => el.trackId === track.id && el.elementId === element.id, + (elementEntry) => + elementEntry.trackId === track.id && + elementEntry.elementId === element.id, ), ), } as typeof track; diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index cf1bd94c..97b475f4 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -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, + }), + }; } diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 3f10baa4..69d94c24 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -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"; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts new file mode 100644 index 00000000..f221e33a --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -0,0 +1,3 @@ +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./upsert-keyframe"; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts new file mode 100644 index 00000000..f12f9829 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -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); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts new file mode 100644 index 00000000..29d95918 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts @@ -0,0 +1,75 @@ +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)); + 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); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts new file mode 100644 index 00000000..370f6865 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -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); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 2fe06694..973d9cb2 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -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, @@ -85,8 +102,8 @@ export class MoveElementCommand extends Command { 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, + ), }; } diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index abe133c9..be89e2ff 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -2,18 +2,29 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; +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"; - constructor( - private elements: { trackId: string; elementId: string }[], - private splitTime: number, - private retainSide: "both" | "left" | "right" = "both", - ) { + constructor({ + elements, + splitTime, + retainSide = "both", + }: { + elements: { trackId: string; elementId: string }[]; + splitTime: number; + retainSide?: "both" | "left" | "right"; + }) { super(); + this.elements = elements; + this.splitTime = splitTime; + this.retainSide = retainSide; } getRightSideElements(): { trackId: string; elementId: string }[] { @@ -28,7 +39,7 @@ export class SplitElementsCommand extends Command { const updatedTracks = this.savedState.map((track) => { const elementsToSplit = this.elements.filter( - (el) => el.trackId === track.id, + (elementEntry) => elementEntry.trackId === track.id, ); if (elementsToSplit.length === 0) { @@ -39,7 +50,7 @@ export class SplitElementsCommand extends Command { ...track, elements: track.elements.flatMap((element) => { const shouldSplit = elementsToSplit.some( - (el) => el.elementId === element.id, + (elementEntry) => elementEntry.elementId === element.id, ); if (!shouldSplit) { @@ -59,6 +70,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 [ @@ -67,6 +83,7 @@ export class SplitElementsCommand extends Command { duration: leftVisibleDuration, trimEnd: element.trimEnd + rightVisibleDuration, name: `${element.name} (left)`, + animations: leftAnimations, }, ]; } @@ -85,6 +102,7 @@ export class SplitElementsCommand extends Command { duration: rightVisibleDuration, trimStart: element.trimStart + leftVisibleDuration, name: `${element.name} (right)`, + animations: rightAnimations, }, ]; } @@ -102,6 +120,7 @@ export class SplitElementsCommand extends Command { duration: leftVisibleDuration, trimEnd: element.trimEnd + rightVisibleDuration, name: `${element.name} (left)`, + animations: leftAnimations, }, { ...element, @@ -110,6 +129,7 @@ export class SplitElementsCommand extends Command { duration: rightVisibleDuration, trimStart: element.trimStart + leftVisibleDuration, name: `${element.name} (right)`, + animations: rightAnimations, }, ]; }), diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts index 6b3a860b..3366548d 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts @@ -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); diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index 51dcb63d..e25bc0e3 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -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; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index 99c63287..6afcb9d0 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -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; diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts index 4ee54726..e6b0706f 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element.ts @@ -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; - constructor( - private trackId: string, - private elementId: string, - private updates: Partial>, - ) { + constructor({ + trackId, + elementId, + updates, + }: { + trackId: string; + elementId: string; + updates: Partial; + }) { 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 }), }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 9e58ec52..cc63048f 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -7,6 +7,7 @@ import { 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; @@ -62,10 +63,12 @@ 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 ("hidden" in element && element.hidden) return null; @@ -73,6 +76,11 @@ export function getElementBounds({ 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({ @@ -80,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; @@ -139,30 +157,30 @@ export function getElementBounds({ measuredHeight = visualRect.height; const localCenterX = visualRect.left + visualRect.width / 2; const localCenterY = visualRect.top + visualRect.height / 2; - const scaledCenterX = localCenterX * element.transform.scale; - const scaledCenterY = localCenterY * element.transform.scale; - const rotationRad = (element.transform.rotate * Math.PI) / 180; + 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 + element.transform.position.x + rotatedCenterX, - cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY, - width: measuredWidth * element.transform.scale, - height: measuredHeight * element.transform.scale, - rotation: element.transform.rotate, + 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, }; } @@ -206,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) @@ -214,6 +237,7 @@ export function getVisibleElementsWithBounds({ element, canvasSize, mediaAsset, + localTime, }); if (bounds) { result.push({ diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index c51667bf..9e191833 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -18,7 +18,7 @@ import type { AudioElement, VideoElement, ImageElement, - StickerElement, + VisualElement, UploadAudioElement, } from "@/types/timeline"; import type { MediaType } from "@/types/assets"; @@ -31,7 +31,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 +42,7 @@ export function isVisualElement( export function canElementBeHidden( element: TimelineElement, -): element is VideoElement | ImageElement | TextElement | StickerElement { +): element is VisualElement { return element.type !== "audio"; } diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index f3ace94f..c7236887 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,9 +1,11 @@ import type { TimelineTrack } from "@/types/timeline"; export * from "./track-utils"; +export * from "./track-element-update"; export * from "./element-utils"; export * from "./zoom-utils"; export * from "./ruler-utils"; +export * from "./pixel-utils"; export function calculateTotalDuration({ tracks, diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts new file mode 100644 index 00000000..4c0c243c --- /dev/null +++ b/apps/web/src/lib/timeline/pixel-utils.ts @@ -0,0 +1,79 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; + +function getDevicePixelRatio({ + devicePixelRatio, +}: { + devicePixelRatio?: number; +}): number { + if ( + typeof devicePixelRatio === "number" && + Number.isFinite(devicePixelRatio) && + devicePixelRatio > 0 + ) { + return devicePixelRatio; + } + + if (typeof window === "undefined") { + return 1; + } + + if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) { + return window.devicePixelRatio; + } + + return 1; +} + +export function getTimelinePixelsPerSecond({ + zoomLevel, +}: { + zoomLevel: number; +}): number { + return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; +} + +export function timelineTimeToPixels({ + time, + zoomLevel, +}: { + time: number; + zoomLevel: number; +}): number { + return time * getTimelinePixelsPerSecond({ zoomLevel }); +} + +export function snapPixelToDeviceGrid({ + pixel, + devicePixelRatio, +}: { + pixel: number; + devicePixelRatio?: number; +}): number { + const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio }); + return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio; +} + +export function timelineTimeToSnappedPixels({ + time, + zoomLevel, + devicePixelRatio, +}: { + time: number; + zoomLevel: number; + devicePixelRatio?: number; +}): number { + const rawPixel = timelineTimeToPixels({ time, zoomLevel }); + return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio }); +} + +export function getCenteredLineLeft({ + centerPixel, + lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX, +}: { + centerPixel: number; + lineWidthPx?: number; +}): number { + return centerPixel - lineWidthPx / 2; +} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index a29737d0..c7fac090 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,10 +1,11 @@ import type { Bookmark, TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; +import { getElementKeyframes } from "@/lib/animation"; export interface SnapPoint { time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark"; + type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; elementId?: string; trackId?: string; } @@ -26,6 +27,7 @@ export function findSnapPoints({ enableElementSnapping = true, enablePlayheadSnapping = true, enableBookmarkSnapping = true, + enableKeyframeSnapping = true, }: { tracks: Array; playheadTime: number; @@ -35,13 +37,15 @@ export function findSnapPoints({ enableElementSnapping?: boolean; enablePlayheadSnapping?: boolean; enableBookmarkSnapping?: boolean; + enableKeyframeSnapping?: boolean; }): SnapPoint[] { const snapPoints: SnapPoint[] = []; - if (enableElementSnapping) { - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + if (enableElementSnapping) { snapPoints.push( { time: element.startTime, @@ -57,6 +61,19 @@ export function findSnapPoints({ }, ); } + + if (enableKeyframeSnapping) { + for (const keyframe of getElementKeyframes({ + animations: element.animations, + })) { + snapPoints.push({ + time: element.startTime + keyframe.time, + type: "keyframe", + elementId: element.id, + trackId: track.id, + }); + } + } } } diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts new file mode 100644 index 00000000..6118b327 --- /dev/null +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -0,0 +1,33 @@ +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; + +export function updateElementInTracks({ + tracks, + trackId, + elementId, + update, + elementPredicate, +}: { + tracks: TimelineTrack[]; + trackId: string; + elementId: string; + update: (element: TimelineElement) => TimelineElement; + elementPredicate?: (element: TimelineElement) => boolean; +}): TimelineTrack[] { + return tracks.map((track) => { + if (track.id !== trackId) { + return track; + } + + const nextElements = track.elements.map((element) => { + if (element.id !== elementId) { + return element; + } + if (elementPredicate && !elementPredicate(element)) { + return element; + } + return update(element); + }); + + return { ...track, elements: nextElements } as TimelineTrack; + }); +} diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index b3a0be74..6f4f2bd8 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -84,6 +84,7 @@ export class ImageNode extends VisualNode { source, sourceWidth: width || renderer.width, sourceHeight: height || renderer.height, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index acf638eb..86f14fb4 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -62,6 +62,7 @@ export class StickerNode extends VisualNode { source, sourceWidth: width, sourceHeight: height, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 76d78b31..0ddb4eae 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -12,6 +12,11 @@ import { getTextBackgroundRect, measureTextBlock, } from "@/lib/text/layout"; +import { + getElementLocalTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; function scaleFontSize({ fontSize, @@ -86,16 +91,28 @@ export class TextNode extends BaseNode { renderer.context.save(); - const x = this.params.transform.position.x + this.params.canvasCenter.x; - const y = this.params.transform.position.y + this.params.canvasCenter.y; + const localTime = getElementLocalTime({ + timelineTime: time, + elementStartTime: this.params.startTime, + elementDuration: this.params.duration, + }); + const transform = resolveTransformAtTime({ + baseTransform: this.params.transform, + animations: this.params.animations, + localTime, + }); + const opacity = resolveOpacityAtTime({ + baseOpacity: this.params.opacity, + animations: this.params.animations, + localTime, + }); + const x = transform.position.x + this.params.canvasCenter.x; + const y = transform.position.y + this.params.canvasCenter.y; renderer.context.translate(x, y); - renderer.context.scale( - this.params.transform.scale, - this.params.transform.scale, - ); - if (this.params.transform.rotate) { - renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180); + renderer.context.scale(transform.scale, transform.scale); + if (transform.rotate) { + renderer.context.rotate((transform.rotate * Math.PI) / 180); } const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal"; @@ -138,7 +155,7 @@ export class TextNode extends BaseNode { ? this.params.blendMode : "source-over" ) as GlobalCompositeOperation; - renderer.context.globalAlpha = this.params.opacity; + renderer.context.globalAlpha = opacity; if ( this.params.background.color && diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 8e2ddccd..9ae63661 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -16,7 +16,7 @@ export class VideoNode extends VisualNode { return; } - const videoTime = this.getLocalTime(time); + const videoTime = this.getSourceLocalTime(time); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, @@ -29,6 +29,7 @@ export class VideoNode extends VisualNode { source: frame.canvas, sourceWidth: frame.canvas.width, sourceHeight: frame.canvas.height, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 7179369e..50451b4a 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -2,8 +2,13 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; import type { BlendMode } from "@/types/rendering"; import type { Transform } from "@/types/timeline"; - -const VISUAL_EPSILON = 1 / 1000; +import type { ElementAnimations } from "@/types/animation"; +import { + getElementLocalTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; export interface VisualNodeParams { duration: number; @@ -11,6 +16,7 @@ export interface VisualNodeParams { trimStart: number; trimEnd: number; transform: Transform; + animations?: ElementAnimations; opacity: number; blendMode?: BlendMode; } @@ -18,14 +24,22 @@ export interface VisualNodeParams { export abstract class VisualNode< Params extends VisualNodeParams = VisualNodeParams, > extends BaseNode { - protected getLocalTime(time: number): number { + protected getSourceLocalTime(time: number): number { return time - this.params.timeOffset + this.params.trimStart; } + protected getAnimationLocalTime(time: number): number { + return getElementLocalTime({ + timelineTime: time, + elementStartTime: this.params.timeOffset, + elementDuration: this.params.duration, + }); + } + protected isInRange(time: number): boolean { - const localTime = this.getLocalTime(time); + const localTime = this.getSourceLocalTime(time); return ( - localTime >= this.params.trimStart - VISUAL_EPSILON && + localTime >= this.params.trimStart - TIME_EPSILON_SECONDS && localTime < this.params.trimStart + this.params.duration ); } @@ -35,15 +49,27 @@ export abstract class VisualNode< source, sourceWidth, sourceHeight, + timelineTime, }: { renderer: CanvasRenderer; source: CanvasImageSource; sourceWidth: number; sourceHeight: number; + timelineTime: number; }): void { renderer.context.save(); - const { transform, opacity } = this.params; + const animationLocalTime = this.getAnimationLocalTime(timelineTime); + const transform = resolveTransformAtTime({ + baseTransform: this.params.transform, + animations: this.params.animations, + localTime: animationLocalTime, + }); + const opacity = resolveOpacityAtTime({ + baseOpacity: this.params.opacity, + animations: this.params.animations, + localTime: animationLocalTime, + }); const containScale = Math.min( renderer.width / sourceWidth, renderer.height / sourceHeight, diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 530b2eb3..2bbcf25f 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -68,6 +68,7 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, }), @@ -82,6 +83,7 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, ...(params.isPreview && { @@ -112,6 +114,7 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, }), diff --git a/apps/web/src/types/animation.ts b/apps/web/src/types/animation.ts new file mode 100644 index 00000000..b9ca91af --- /dev/null +++ b/apps/web/src/types/animation.ts @@ -0,0 +1,89 @@ +export const ANIMATION_PROPERTY_PATHS = [ + "transform.position.x", + "transform.position.y", + "transform.scale", + "transform.rotate", + "opacity", + "volume", +] as const; + +export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; + +export type AnimationValueKind = "number" | "color" | "discrete"; +export type DiscreteValue = boolean | string; +export type AnimationValue = number | string | boolean; + +export type NumberKeyframeInterpolation = "linear" | "hold"; +export type ColorKeyframeInterpolation = "linear" | "hold"; +export type DiscreteKeyframeInterpolation = "hold"; +export type AnimationInterpolation = + | NumberKeyframeInterpolation + | ColorKeyframeInterpolation + | DiscreteKeyframeInterpolation; + +interface BaseAnimationKeyframe< + TValue extends AnimationValue, + TInterpolation extends AnimationInterpolation, +> { + id: string; + time: number; // relative to element start time + value: TValue; + interpolation: TInterpolation; +} + +export interface NumberKeyframe + extends BaseAnimationKeyframe {} + +export interface ColorKeyframe + extends BaseAnimationKeyframe {} + +export interface DiscreteKeyframe + extends BaseAnimationKeyframe {} + +export type AnimationKeyframe = NumberKeyframe | ColorKeyframe | DiscreteKeyframe; + +interface BaseAnimationChannel< + TValueKind extends AnimationValueKind, + TKeyframe extends AnimationKeyframe, +> { + valueKind: TValueKind; + keyframes: TKeyframe[]; +} + +export interface NumberAnimationChannel + extends BaseAnimationChannel<"number", NumberKeyframe> {} + +export interface ColorAnimationChannel + extends BaseAnimationChannel<"color", ColorKeyframe> {} + +export interface DiscreteAnimationChannel + extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {} + +export type AnimationChannel = + | NumberAnimationChannel + | ColorAnimationChannel + | DiscreteAnimationChannel; + +export type ElementAnimationChannelMap = Record< + string, + AnimationChannel | undefined +>; + +export interface ElementAnimations { + channels: ElementAnimationChannelMap; +} + +export interface ElementKeyframe { + propertyPath: AnimationPropertyPath; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +} + +export interface SelectedKeyframeRef { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 5a98bd31..26690458 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,4 +1,5 @@ import type { BlendMode, Transform } from "./rendering"; +import type { ElementAnimations } from "./animation"; export interface Bookmark { time: number; @@ -80,6 +81,7 @@ interface BaseTimelineElement { startTime: number; trimStart: number; trimEnd: number; + animations?: ElementAnimations; } export interface VideoElement extends BaseTimelineElement { @@ -136,6 +138,17 @@ export interface StickerElement extends BaseTimelineElement { blendMode?: BlendMode; } +export type VisualElement = + | VideoElement + | ImageElement + | TextElement + | StickerElement; + +export type ElementUpdatePatch = + | { transform: Transform } + | { opacity: number } + | { volume: number }; + export type TimelineElement = | AudioElement | VideoElement diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts index ac82ca17..d45e71c0 100644 --- a/apps/web/src/utils/math.ts +++ b/apps/web/src/utils/math.ts @@ -10,6 +10,18 @@ export function clamp({ return Math.max(min, Math.min(max, value)); } +export function isNearlyEqual({ + leftValue, + rightValue, + epsilon = 0.0001, +}: { + leftValue: number; + rightValue: number; + epsilon?: number; +}): boolean { + return Math.abs(leftValue - rightValue) <= epsilon; +} + export function evaluateMathExpression({ input, }: { From 956f3d201b5715650bc967719cb15b7821787e25 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Feb 2026 18:15:36 +0100 Subject: [PATCH 14/24] fix type error --- apps/web/src/lib/commands/timeline/element/update-element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts index e6b0706f..643f8e4b 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element.ts @@ -32,7 +32,7 @@ export class UpdateElementCommand extends Command { tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, - update: (element) => ({ ...element, ...this.updates }), + update: (element) => ({ ...element, ...this.updates }) as TimelineElement, }); editor.timeline.updateTracks(updatedTracks); From cb8f0034782a04556c7a0aa45a64c478eb645e0f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Feb 2026 18:23:13 +0100 Subject: [PATCH 15/24] another type error --- .../storage/migrations/transformers/v1-to-v2.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 098101d6..d16106d9 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -450,10 +450,17 @@ function transformTextTrack({ value: textElement.color, fallback: "#000000", }), - backgroundColor: getStringValue({ + background: { + color: getStringValue({ value: textElement.backgroundColor, - fallback: "#FFFFFF", + fallback: "transparent", }), + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, textAlign: (getStringValue({ value: textElement.textAlign, fallback: "left", From 6226ceb0526047dff1dd957d38a77f36b75e1bfc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Feb 2026 20:34:33 +0100 Subject: [PATCH 16/24] fix coderabbit issues --- apps/web/src/core/managers/timeline-manager.ts | 2 +- apps/web/src/lib/animation/keyframe-query.ts | 2 +- apps/web/src/lib/commands/timeline/element/move-elements.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 11c976a8..e0fd9915 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -272,7 +272,7 @@ export class TimelineManager { propertyPath, time, value, - interpolation = "linear", + interpolation, keyframeId, }) => new UpsertKeyframeCommand({ diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index f3288e7a..6626e3e8 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -54,7 +54,7 @@ export function getKeyframeAtTime({ const channel = animations?.channels[propertyPath]; if (!channel || channel.keyframes.length === 0) return null; const keyframe = channel.keyframes.find( - (kf) => Math.abs(kf.time - time) <= TIME_EPSILON_SECONDS, + (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, ); if (!keyframe) return null; return { diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 973d9cb2..6aadcf16 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -98,7 +98,7 @@ 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, @@ -124,8 +124,8 @@ export class MoveElementCommand extends Command { }; } - return track; - }) as TimelineTrack[]; + return track; + }); if (!isSameTrack) { const sourceTrackAfterMove = updatedTracks.find( From 50885fb976f9a609c5f94359f548038fd44e2b3d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 28 Feb 2026 00:26:09 +0100 Subject: [PATCH 17/24] fix: update review checklist terminology for clarity --- .cursor/commands/review.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.cursor/commands/review.md b/.cursor/commands/review.md index a2071ce8..ec3e1a20 100644 --- a/.cursor/commands/review.md +++ b/.cursor/commands/review.md @@ -134,7 +134,7 @@ 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: +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 ✓` --- @@ -143,7 +143,7 @@ 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. From a9e93471a74a3d1978278f09f35632d4f6588a76 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 28 Feb 2026 01:12:27 +0100 Subject: [PATCH 18/24] review cursor command --- .cursor/commands/review.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.cursor/commands/review.md b/.cursor/commands/review.md index ec3e1a20..4361b397 100644 --- a/.cursor/commands/review.md +++ b/.cursor/commands/review.md @@ -147,3 +147,18 @@ Before outputting the review, list each checklist section and confirm you checke - 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. From 216e3e0c397be3719a94e1a22bc1c2ce2de3856c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 28 Feb 2026 18:41:22 +0100 Subject: [PATCH 19/24] feat: introduce WebGL effects system and Blur effect This implements the foundational architecture for video effects, starting with a multi-pass WebGL rendering pipeline and a customizable Gaussian Blur effect. Key changes: - WebGL Engine: Added `raw-loader` for `.glsl` shaders, multi-pass framebuffer rendering, and live offscreen canvas previews. - Node Architecture: Replaced hardcoded background blur with `CompositeEffectNode` and added `EffectLayerNode` to apply effects to specific visual elements. - Timeline & DND: Added a new `effect` track type. Upgraded drag-and-drop to support dropping effects directly onto the timeline. Consolidated track constants into a cleaner `TRACK_CONFIG`. - UI/UX: Added an Effects tab in the assets panel with live previews. Added an Effect Properties panel with sliders and inputs for fine-tuning parameters. - Data Model: Added `sourceDuration` to video and audio elements, and wrote a v8 storage migration to update existing projects to the new schema. - Docs: Added `CHANGELOG.md` tracking v0.1.0 and v0.2.0, plus `docs/effects-renderer.md` to document the new WebGL pipeline. --- CHANGELOG.md | 41 +++ apps/web/next.config.ts | 8 + apps/web/package.json | 211 +++++++------ apps/web/public/effects/preview.jpg | Bin 0 -> 48179 bytes .../components/editor/panels/assets/index.tsx | 7 +- .../editor/panels/assets/views/assets.tsx | 6 + .../editor/panels/assets/views/effects.tsx | 95 ++++++ .../panels/properties/effect-properties.tsx | 103 ++++++ .../editor/panels/properties/index.tsx | 65 ++-- .../editor/panels/timeline/index.tsx | 12 +- .../panels/timeline/timeline-element.tsx | 266 ++++++++++------ .../editor/panels/timeline/timeline-track.tsx | 3 + apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/components/ui/slider.tsx | 4 +- apps/web/src/constants/timeline-constants.tsx | 82 +++-- apps/web/src/core/index.ts | 2 + .../timeline/element/use-element-resize.ts | 8 +- .../src/hooks/timeline/use-selection-box.ts | 18 +- .../hooks/timeline/use-timeline-drag-drop.ts | 69 +++- apps/web/src/hooks/use-effect-preview.ts | 47 +++ apps/web/src/lib/animation/interpolation.ts | 5 + .../timeline/element/insert-element.ts | 5 + .../element/keyframes/retime-keyframe.ts | 1 + .../lib/effects/definitions/blur.frag.glsl | 25 ++ apps/web/src/lib/effects/definitions/blur.ts | 50 +++ apps/web/src/lib/effects/definitions/index.ts | 13 + apps/web/src/lib/effects/effect.vert.glsl | 7 + apps/web/src/lib/effects/index.ts | 34 ++ apps/web/src/lib/effects/registry.ts | 31 ++ apps/web/src/lib/preview/element-bounds.ts | 2 +- apps/web/src/lib/timeline/drop-utils.ts | 85 ++++- apps/web/src/lib/timeline/element-utils.ts | 42 ++- apps/web/src/lib/timeline/track-utils.ts | 38 +-- .../web/src/services/renderer/canvas-utils.ts | 16 + .../src/services/renderer/effect-preview.ts | 194 ++++++++++++ .../renderer/nodes/blur-background-node.ts | 75 ----- .../renderer/nodes/composite-effect-node.ts | 77 +++++ .../renderer/nodes/effect-layer-node.ts | 82 +++++ .../src/services/renderer/nodes/image-node.ts | 2 +- .../services/renderer/nodes/sticker-node.ts | 2 +- .../src/services/renderer/nodes/video-node.ts | 4 +- .../services/renderer/nodes/visual-node.ts | 68 +++- .../src/services/renderer/scene-builder.ts | 256 +++++++++------ .../renderer/webgl-effect-renderer.ts | 73 +++++ apps/web/src/services/renderer/webgl-utils.ts | 298 ++++++++++++++++++ .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v7-to-v8.ts | 96 ++++++ .../services/storage/migrations/v7-to-v8.ts | 16 + apps/web/src/types/drag.ts | 15 +- apps/web/src/types/effects.ts | 45 +++ apps/web/src/types/glsl.d.ts | 4 + apps/web/src/types/timeline.ts | 38 ++- bun.lock | 169 +++++++++- docs/effects-renderer.md | 64 ++++ package.json | 62 ++-- 55 files changed, 2510 insertions(+), 537 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 apps/web/public/effects/preview.jpg create mode 100644 apps/web/src/components/editor/panels/assets/views/effects.tsx create mode 100644 apps/web/src/components/editor/panels/properties/effect-properties.tsx create mode 100644 apps/web/src/hooks/use-effect-preview.ts create mode 100644 apps/web/src/lib/effects/definitions/blur.frag.glsl create mode 100644 apps/web/src/lib/effects/definitions/blur.ts create mode 100644 apps/web/src/lib/effects/definitions/index.ts create mode 100644 apps/web/src/lib/effects/effect.vert.glsl create mode 100644 apps/web/src/lib/effects/index.ts create mode 100644 apps/web/src/lib/effects/registry.ts create mode 100644 apps/web/src/services/renderer/canvas-utils.ts create mode 100644 apps/web/src/services/renderer/effect-preview.ts delete mode 100644 apps/web/src/services/renderer/nodes/blur-background-node.ts create mode 100644 apps/web/src/services/renderer/nodes/composite-effect-node.ts create mode 100644 apps/web/src/services/renderer/nodes/effect-layer-node.ts create mode 100644 apps/web/src/services/renderer/webgl-effect-renderer.ts create mode 100644 apps/web/src/services/renderer/webgl-utils.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts create mode 100644 apps/web/src/services/storage/migrations/v7-to-v8.ts create mode 100644 apps/web/src/types/effects.ts create mode 100644 apps/web/src/types/glsl.d.ts create mode 100644 docs/effects-renderer.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..4ddf2a88 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +## **0.2.0**: + +This release adds the foundation for motion and effects. + +**Features**: + +- Keyframe animation system, starting with transform properties (position, scale, rotation). +- Effects system (with our first effect: Blur!) + +**Bug fixes**: + +- Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below + +**Improvements**: + +## **0.1.0**: + +This first release focuses on making editing faster, clearer, and more reliable for day-to-day use. + +**Features**: + +- Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in. +- 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. +- New color picker with eyedropper, opacity control, and multiple color formats. +- Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly. +- Move, scale, and rotate elements directly in the preview panel. +- Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now. +- Paste images, videos, or audio from your clipboard straight into the editor. +- Hold `Shift` while moving or resizing to temporarily turn off snapping. + +**Improvements**: + +- Expanded font selection from 7 to over 1,000. +- Fonts load much faster. +- All asset panel tabs now share a consistent layout. +- Text rendering properly supports multiple lines, line height, and letter spacing. +- Better contrast in the dark theme. + +**Bug fixes**: + +- Fixed undo/redo for drag-and-drop so dropped items are reliably undoable. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index fa1ac7f5..c3676f0f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,14 @@ import type { NextConfig } from "next"; import { withBotId } from "botid/next/config"; const nextConfig: NextConfig = { + turbopack: { + rules: { + "*.glsl": { + loaders: [require.resolve("raw-loader")], + as: "*.js", + }, + }, + }, compiler: { removeConsole: process.env.NODE_ENV === "production", }, diff --git a/apps/web/package.json b/apps/web/package.json index 9a57a379..90aa5d3b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,107 +1,108 @@ { - "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": { + "@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" + } } diff --git a/apps/web/public/effects/preview.jpg b/apps/web/public/effects/preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6519189c06f6199dca0d3cb1e41ca9aeb679ab66 GIT binary patch literal 48179 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°iWOpn2~Y%6zM#4)=*3yk??<2 zqQC%vQsG~WNzBJz5GzJAC#z)83s^JGk%4=U}Nzezw&2hCo(Aby(u)#AU9_ zr`cgg{!Km$m)pq2<6t?xfOg7S}&!WW8U% z^RZ5ux$Xi^#V!@g`itg3e^FDqt;z81v9Q6OUKnB6d-=t!dF_@c^5sqr^z_KZTk~d% zk4$Wt1QMMo%aMJ0kImcVExUWcMVsB@40kzLB`ScG6(FY2l+}Fsh>7Lf0pD5<$cEM% z4C5Idoe^2TGv~B+@?I+-Nq^|{xkZ>spLU%;|Fy96!h zNFr(FQgH7t}QO_HSbwqFM{|UuP@H;IXYVuT!c@-;#Kbo zybAD22|`w{@{SH$f85~>rXtK+p|l+*gs&MBhg+z#WxnLvkw$)e_bE8N`2IK>dtq#- z{t{Zhbl(d;_r*eg3)fRE>2s|~)H=qtxv^)re^~4N0gT3%6ZIRob-***pjD9*b+In9 zHn8b1CZ%`sYXBr09{XrGK%7hqSDoBnRAiH9(e(Jy7&JAJ< zW$)P9{b;xu+Mm3 za;$ZE$cp~+kc6PV%QuhUnU>BI)wHop_K$A)V%mOCxpW&?d_)E^-9omu%cl1*s=2)^ z$CX}t)WuI{cPqs2o3018w^uWlCY59CCF9HG+1$16%%h*E$)$q55&GSq<=F|!RShqH zbOz_`8AK1eRjDY|G_3jh+>qu9NLD!Q>?qf2Fl-5eA=igd#okG*A3t9MX2VG`FgOJe zWQd0^&HPRcbfG_@)|YMs{=ohSStCFIB5+A0{ePJ=V5SaWF7FZt0agG$=znVe_gMw_ z|FnSgr8q)T4X0|MdumtoHQJYRwMOK#vzGT}|AIAUXCSVM72;rc-~4&C<{TKh7#MNu zBOs!ed$TbkwTGTB4jbdb$o|AX|G+vcYAa?=@US_lF)~{|Jpw6qBs>@vQy|bVR&^T{ z*v9%Bu*(`X<77Jc7`^Vzv?r}Yo@Ps|XKJgu@Z^xtTaKS#V_93Y8G_tWTKn`c=EaV32LVr;y`w@!CT9$FI!K zCPRkmuGl-(lGt0$_>b~5w8ZV>Btt^i)&{$8K3Nnkbfv6AzL{XnROIcdzB9uUgOp`r zx3$t6Da}yzj1}M!lqX$aEf#X)_x{XL&pXJ2vv|6^pzruWflo(3IC(sF*`>WFKtyED zCb8XhK(|APglz66w7W!wS zab|SeXdbR#eR;s5KtiuKf}#315W%fwgYJvAs2kLaG@YE@NQgu@Pru#6T<@{p0vzL% z$fdDmV@|A`u3R_f9eIm+C)8E1ExnyLwW2e_)7F2s1d&Tb3njEL1m%$HD6A@h(#Y%1 z;q4$&w5E&P`l+Aci#Lg8)u|>T`W(I=`f}0q@!7>w!c{^9S#hEJ-BWGHeAnwAoWs6) z^;)wiG!z>H$yZeuCER}GLpF{4H zOjzab7Wh?+sFIX%(u$Uqa4u{L)+`RB{=CT5KC93lYa4B!cxG@~zgIK;UT#-Qd)ZRD zF}RI4O*Cl=ftR$;MF^}0{h7NUYET3yxeY+zz-2@Vk%7PvmrGqxjQRg*%RnG-nVh{0 zb|EM@IQRW${sb8Vmn3gI%q2efANvyqLjYi*SHKuJjxW%1&*$IxaSY2lELC42JsZ2S!bn0Mzsg`{DsKn#Htk6-O(cb4f;pYVEc^Ww83K7Ng(cPKz zr?T?)P5$RzJjW(E$9lK8k9g_C(^Xz8_Kzvdp8mR!#q;sc56u@-a#k%Yz$teveyV4> zJ()_eqZKx*zf;HgR8wA-Wc{b;C+&_zc;T&RH76U@Sh`_`$}!3oL2^{i(p2+d2kG6= zB~RW0#wd4F7GpM|P0@|oSNIIvQ)crm6oEyJl20Yc2R?Kw7ua0*UtxMgC-gF=yqiUy zN>{z$>a48pG*o2uX&&(&b6#*MA^b57C7*hqm%V9=q>@@Qq0;>EHDO7B>FP?~+ zR3u>)v!0m@%Xy1Qs$TmT#{Sj)y~<8#J)i#aZB63OPv#s$P}eG7@@5&IcINRF$lVp- zGL`#K#hyTHDub4?C;g)*g?gbU<|c~f0a zxoRs$S!EcPE5GJSE^Y9ig$4xcUULaG7ZsHi$_|Sa1LdoBK};Z)szSY*ym~omklx3w z>Lvr>K}un>eo)Gz;yl~|%*;Di9lg^a>UHc@_iNu<)Kjdb$lzhLZZfKkPP6WNRWu+f zyeGUwiFeQGDIPU6IBJIyHeDeDY42Stg(xL+$l|#RZ|um1R_0Bh>N1f@Q#RV>H^Y>R zf3W+8-hVUoEcxg@*36T-vs=mP>1XBgnIrPWK0{B=CnfwnjvZ8+AcMXoers8$JsLS> z7dbH77g>aMEp3P_N{lO7UY)u=ydRCy=8LWmFLaN!=58#a^vgHdVw;#%K>M5&%UJC5STVsINQ}QbYhKYmdjQsDj8^y zbX7g(RuznXmHCA=BCm~$`%A0KMgBLfL+g&dsd;JnT;cm|8-r@J_~dH0;2CV-Mh@u0 zml|vT85BT;{(q&t{^jO>N(h`B4+G$XG946(+Z%bmS##+dq1KLrBsPD z5s4YJ$!NVUBvDa{Hdigq-6|Z`YDyg7Wh|tX<7|*MQ2(N}@;xxuqk3y(q^QpEyH%Wy z?P+scqT#{?DeCT|kJbbbJo8%HaArs=<^tSmx&o8WbD@o@aVV=e4F4Qy~`(9gD4U{ZJ5 zvU`oz*>=L>lj){@O?(Du-Ri`}`4D%rz0F=bXV1s+O>NB|q%P6TwGM@C$+#uy>~&1l ztIDR!eY0scoDn}~DnG5LSUK1zxsg3kBHwdjsEQLU6z-^&n&aT+G>kkU=3k)W^4eCH zG;h?~wZS#2%CE-cPilan{$jHs>xGcys}e5;Dl()6QSa@@4ijm{myBR-7uN zMf1+`w-+>+75w#)ikhnmhYUTEH*{Y(V5bhV>C*Z+uKH;7P||itc(!hR*=d!1=+Ja> zA$rYVbKi{a;Ikp8%SoVv%Eh%4o0q=_inSl&a~Q}0APoneIJ7r6j#ER{j< zBAv^ojc=7LLgfg(D_15kr{`}#CzSn3 zC)>sio1SDfJ9gc}Wl54CC;uCE=tWRTLa5CBVr8${i-=Wc!`*=?r}^&D%=>{Uh7ARU zl_ob%Te|f61*cM)bP7B@2G{8;TTYdJ1AERL@>&vJslFzVa~Aha^eZ36xwgziosFfU z%KBGT;xh&*{39zmPTz}R$1Sz`UQ9hjjrE-!oT>NLy#fo{8G?A$mTkt$_PjS&o8(86 z($M7ZSyV%v^*F_q z;*rhcQv})sLjGDYg7P*(jqpo59Y+9I4WolyYkVUdkrI2L>box8Eepp8i$AF4Qto)&m)ly(_v_QIhy5FF zPbb`qBk&lT&&^UZ3^r>OfF#bdBznZlpxEH1))I$Q8f5K%u#^aB5d41>9WR3sD1z*t zkp%$_H@X&dS-BK_2ltyVH6+1k<-d&?0|qu6f45vpHC@u2z@vO1*MK{mQD&j<2x8as z+8vv{Gfp$@ZT6PUv(2(6VDjzf4`2V-*0h_vu-r~zKYBTNlAVvVlNA|0W`dn0Rn9-t zdHl;=OI_e@agyIyWU)?Su4G4Z(Q?VlTldFPGD32Wb1TjQzkW`_viK2hJ}j~lx!+OG zYp9}3j-oE46%cdS+aRZLXpiw7^SvU4s|fiX=)%-}ch(A|%LjJT@IpD8Pa zLsM3ryPDVk7gnObASri(F!%0H#cgrogfJ7C69VN`jeHC3L+oZm)e2{PY@hhiM5u)L z$-#b1(voSSgK7D|Ae~U|;96`tKD}rQ)!u;8((IGf6H^3KsL72YsbfhbRH_+%Q&*>{UIDVkxR`|&U>H9D=-PlJm|S15tt*Hdj4u;-a>JvJ*ITZHI>CGHS4+OlO|5GJ^m~SrL8XH{uJn6) z?@GV6jS-=I-o-q@EoemRT#2xZ5WDZB^kzK4ty{pS|Z{PdB1kBwdr8J)Rsj zsqSti6eULZ3jPMF&T4jIFZ$?HJe^W{=nWb=?3Z3?%bt?TAg8UC0 zzEAu#eV)=~$Tj1|UCTZlC}%m0o}&YIhmXFh0eQ=oR>;$x4q=K-nI2R$)5{Stq~dQF5sO!6DNz z7}DyGAW*Vu3s4s^W`=0hiDGUtn$_a*n@S>JmJ?CY#8Bn(Q)@hwA$u~%w`PF2(L|lX z0ZyP<68%^8A;35`T-_50F^_S~NA+P~Pp9qc0P+^T3?sZ#)>nz+z~sw#K7;dGC_5ib zod2Zp58wCXhH`7Z4#jJBR;S|&ODTS*nV;pn6g(c}VZZs_z`D%ns>PpZ*mo&eEc@B3 z47ec}$_HMa8151M+zn$p%D>LsRQqer^$3vMF*&g>h}XhbkBQ0L*@a$v0pMXkfCxxc z{BPZ=KORl~Px9bW><16oODO|D;FmYp5Ma(991*}8=Cb}@297TnC<0u&{L7$4aR9Qg z@Mak$^%8wg)0QnZ5r?2xDNfa(i51LMuI1vKXvra^bKb@oMVG&FkQDjE$a*Ya=u*Xs za?O5^_U-x&*j$X7otxxeq}SV+?eg~73otD^r#hX5lPtw4cJp_1)k$4pM-yomE0=$^ zHLKc_-L^Pn{jB2PO*@$F7Gpm(=+>F?U84_Ee|-nV zV_ipU%LasT-!tr(Wzl*?cB~)la%c{P8lDKw)>YZsKmBZ1I$R-SYnC%^!%BZl5h5TMWDR_6;c$sBiQ!YA&+xjK8!6iU6MD#)xze3`?`8*KF+RF({T+FwS;V@CV5 z(e%Rr9nBN%uHp-`Be5SJ#-0qf`w75OjL*UcbFj%ODrfBMsjL z2qoT`dmei?^{&SZx9~xS$Y}o7khZ|-5`o1552KIEXbby|krQ980*NZ7X{?{Jhd_*l z*pQG=YDkf`=*g(0w$`sI5wUMr;pZ`$7i(WmrakEMXB<7a^VhMM1E~(tNw(e1lK4Yo z!*l)SzSIn{g_0Y3Yb)c&HNl6!#ug^zG^awNL*v4+5Kl;FVB;(}|{T$Bf7gI1WYwF-!6Vzae|@GO+TQS zfuUHdV=pY%SUUICdh}kvP6@Jb`3EkU=*0S{6G_!zDd7{`N|-en z^1}HG9u_eP3$S+S^GnM=zdU(N8vZA{@GtiSOHgo*4V?3(5o%;$V&dfo!1Pa$0B+SO z0Gc4y1%8>K_`i0^Q^flt&iLmJ2y}tq0no7TaGn`u%b!1Uk()5*k6umwzUmU9%GvwF zJA=GDsJy+QV%@yPqA~YA@1;GN;dR!**NKfwd%uCf+vp*hCMlKmzJ52gmje{{46?m^ zV;pFdCKJb6dz9C?VZ&~06`S47*R|-^CY;M&+dfZTtdGeDfzGJYgCd} z>q0H@%odRiqqRK39qyX$p^AYbONrfo$?2Kgbd(8W6Fy9o)4A>Rc+;3auQ-S?fI}hf z74ju#W#=`)A$G2@)tpR)(uy-zXIS=EfJsHEeNj^gz|yf8MfP=1k76 zBuwF8&%66cctJpt-dJVLeE97B0o8knxjx(L5eF&vk_G0smX`M=n0+5sy7_DL3qId* z_iD-ZG{DhU>Ck4K(M1qWEbn+3%;9z=vl$G-%MwncdXKFei=rC5I42f%ycy)YO;#kk zQ#DO^o{o=cW+YwS-;$md1i)OSHVcfYp-`Jv;!MRm)Qj{KLy zXZxz7*7x?gbNo7@^?w72GxPfc&Of~hTAoCNAP;0U==Xcfoxbgxw1j9(uy5dMe2UIy z2Jq#Jl+-V0@MM3q{?~(8QLNLn`L`O0h`^IM4YnCG&8$-s%gJB$+Pi(deobep zeXd;xdlF6h?H$Elue>HBU(x+gIlfqv*!=i(CvO0obn>%m6Q>(Fug~*cjcnwD-!6UX&11`rO z*rxO`x9qp3!@W04A>VM^_@!}*nB|y?sHQ&KR??IKN>zJ@Wlem}=^^&%%&=}uHTqO9 zt0AQUBky{ze0&9OWc0PHykF;3T((5+q9$@iOlQ$xTJefKr{p!Pi&^eh;52?WrKL_a zC#k&I+U8B+zNEy^SgI;o0KUm8mfITq@?bY~O*GI|^O-u=dCztMJo7FPKm^SI|6S6+ z{$ny2P{Qewba@FKxbq_Y9e@`ACQCrR3J5BIUm^g&@Z?g){@)lNumOtqLX8=00c8rw zz`F#38)S5(?rc}mRTeezM4m1#myprWp$uIWod)j?(5>q$J`bZj&-L^lA#Ouwd1xPy z#@<&3SD{*s$v<_t)0e%1vH3+&dW`XmyhFCRvV$ch_QhILD%?WD(nC8^ z^4!REqxiaXGgv77)hbz$8kniHad{+N>&f)JQYo2}8!u?&N#=g^C&FEr-QE zbFDq8s9Xb+QDi1*N|Mz_V|LtuLx>=kV^!bShwy?p8OS%*Z3^P>UAbOkN;AvMbm^PM zVKH)1PEDV9hqC*hJaXAZKaIWTsC^(VI-A(B9HU> z1!tXjxXj->cbG3`8JnS)Z24&Iy3yioekD9pEPCY8g-Gq`;|`=jwykZQ^Dgh|R3f@2 zZ#Ybz#xr=?L}IR2&&oBH?EtlItX{rY>b3#BCi>$2Ccq;B;=O-B!Z&&K-@HS!wM&2b z_MlkyWmCWvGjWFD8qV#$6BzlH{ z^QRfEG-CafZ$*9C!-swPlwm5q!~5i-2Kl)4o3GR}9RumvIAvx3mCF`b9x}g1SBo)l4!KG{C(z7!Xt3)iM3R@naguu;~@o-zQ|m za-_j-O~0>7Ec{L!yd}s+oV@FFNX&>68qPk3$m>nrVCIh7#z0(Ymy)KjF@Vl_1U@V^ z5W+3goUyJn4a97|;q+~Vmv5-9HBSS1uWhHXu48aEQ6KCJ&2oGhb8k!c$)mYA43fIp zlk{w^lU1zUb61`ql@xJxl;2Gtjsg&DNbnTd`spUcQV0ayRL-B4h{!jqcc+{_bpjA zTl}mT!9ZV7U?rUUX<4;!bwQt_R-iKTO(++CsIq;z+AKvx(8{{n0ku1;GAcZ|hj~~} z-;4!Umep+8!p^{t%X1a7cdXE}6?jR<@_fC1+h0V%gI>A&oYlUmL+A9Vs&^;VtytWXaCD4XLLIC>ql?kW{0ZAu z29wrjkw=DW!|dKYES8l~3k@fC#XrqH=fm!Ak{gw=!l;EvnlwmTHBB861jcWRJKOoB|UDy^~F2N_X&Sd^?8|yzB$m zRnC_s1t`b9vIYsUU=mixyn6I$E4Oa?U9nC>2bxLu8Ruef=3^X>FE6GZ(TDTQdH zTlnMAYwPy8>gjcFH=51j4-N3_ay0YNu7ZtP({WEwgAq{*cdS(rMHM#MnJQQf#ndY1 z+Nqj^aPhvX zb3i++l_bX*fj>%J3i+A=u!Zyh((W;2NWIUkGE`R1tlhyJ&TNdu^SVwLMkw@@qG*mW z?Y7y2DK_|f!={TAlD5(PbV#J4r2gFUMhER%D$o-lCDrQXZ)`E>dVg}_@ z{o^#k9Q$#^9gXkZr*K&p<+g^`zNy2kV$7*moqWDR>PVj~5qK9Pg3o1F>Ev5hjS?Gj zJ)}6BJ3=p5M5O$%ucyLAx~`%QF`!Fb9#01r@V_&3;Ni@0u==-!Bfh~YK=?WajOQ5( zIHa#e8iPoje{TNY0`yY_01pENncHdrdThFklIEM@J^3i$c&=~;89n+`?rpIv&#MK7 z4HeanN6CuQ+6>%lqMoi2dPcib`;tWTA>WupL}Srr8cRV;$oe)fuBZrqwJhw^)nVOb zbKC*HFL#c9zr@t8gW5!qM21?r>T;A$^*|qeHg3f0BBwx!PHoOT>&2FE3=uj)f=>7Jla+V zr6CobOAX?wUJd4s<0Tx^1&W!UHW%{WxPBXM7YZvY`qnY;aWb2$n9?*5^LS09xYl6N zYeJc6v8qX~N{>78`5ey4YlR04huxauQDZ}y4Gk9~b$g>*ThEgwK~W)hrq=F}b?XuD z!xDy|Kpv6E!EXmPQSSeOOkK*-GGi`b&zFNR1djI(4|x?l_?s+n0z>7~1K^>6Ti(;n ztHA3z8NkqD{z=iNwC8O2HvMekU zJ$Wl?o)YFU&K)mGEj{2v&gNfG%{A93+H++d18?f0S505G_!6!Al!86Hv z={}k-%jc7OwbFz?V{>N;q6|2V2&ELEPz>Q8{cBLOnhkuB0Kkd>KmK?T=m1CnA`ibR z1A0yY9IWC65QChMG5`+){QnxEQSf!^sErVZN3fBRP!slzNHPZ61Fw`ryJw>1a{=NJ z`3DAjO=%ZTe+V3jRBqljUpvW`QqA4^-jF>Iw687ofQ8mq6{V3M@2Vgo^&)ff*jEwW zB-Ugb@@b|=XsR`)knkK{MqPq5gw}LKNxB1=~57YN$e_1V?)nhMc;%4NOJ+fauU%^%Q ziL-Fk)U)4f7hCE(S6*0K&}Cq<*%&#Oi)A%X?Xn*T7WA+XY<{_VFdXszK)_c8MR#f& z*S!)etU@;%GS%DDQ&MnP{-8sOdUbtCFWF4pm7(u$zol`>IxFWFAnhv<0DxWfPqN_; ztN{;mIcfob%vF47D;^mJVh(oeziyU7uGK;aUjaIL9dN=mr+8>^Bb7IBOP;?Q!}t_p zq>2A{Jdebd7(<33YSzA8OO7@(1zh^}kRGuR**pYZbqNYX)?ErRxF=q@n{EUHb#cKT z0qrIZXS?Yzju(kg?4VGug;>1N zwxA`K7E}lk^2Sg#HHuK`0#sFFjG9B!KyhqP-_d7)({H-Gy5oZyxx0_GQoKz+f(WTy z_vAQ^jeq9&$MrFu$IJm6&W> zWC#FEw_Y09I|2MVnA_m%yaZd5Q2+&CD+$@3sr1hi|NHUxlBqZd5LWDQ1{uN)cRL zzJG3yaB??e+h=BNF@k;MEG0MkL^AW{lH-Lu>$V*4+<`6)XX@~tL~7TDM@!)bxg>au za#Wvi?O<5NbQ0DN?bb1vaY}e+PBu*CzLfEpYTNeDvLC)TR_MoG zo@mXM_|X0aP7JCq`sfZf{eJ_6sS-b>V!Trr)(kx)Z{Jw-HT5pZ@8Mh$e~z|cK>HlE z9Lv{%Ae_T42HN;N>j7*2QIVQ`zdJV)P8d3V1BTK+F0gjESURq_%D8v_K7JOxq7iz9 z!{3tcs|Rd{gjlr{M=&r{ZKo7r-3`yi-kiFbte6i4c+s{aD6CYY!}Z_@x& z=q`N#JpLUFmIwi^@IYYQn={Z|0g$#tma?WJ7>mdtEJu3)XLELG#UU$U)$DCr?N^ZU z5EcZ5XtA1_p}Cv@5a>hg17wUFu-KjQkDS7M!IOl@AyTjLIHZE0Rj27892o;IH6md8?A?> z8Z4yjzR(*oI(pvfUxc=5v`Wi*$o|xOX^@{S3z6#U88c-n=w05HwzVTyzWBpO^PTY1 zQpUV@e;fu61tm=TldA-=$Bcnc;>*`b499>A6X(CVv>+x+2E&g5oh=QVWToN6ftQOH z;F4(%7NB7+KQ40+03d#pe^9zAYBge~&mHR=7kCC@lV?nDUiBe$-DJLHI{NlVf(TF# z4aCT(+i$6CbaYK)7)QrJ)$oGIt`!!9b}Yaf<7WFq*;}tjQ@ZS)OLq^?3FTE@)n$31 zmJfhTNl@Gulx)hEspo8>Z52rdKwi_F=fD9#w>W3jw}CJFea9&x5OynNvF`9WsU&{Vv+~Y%Opzv<)BOi zynw5LX8_`BK4uIK_(HCZKvG1q1_WC_1)>nd8G-;ed=F4O3x0t|0zq;cQ{$6!bpnMf z4T zYfA5X*4%yGfWUVOnT8ft@-&8UG($bqfAxek#LVF7aqYVwD)wxyFPz=0v{vJhi*Gy~<-{BD>p=gx-k2wBG}i2Jr1+uLQJbkqV8JJ>M~m zQ%2fg4=jn}bwS(s!x?k>tiiExadI4buS&ad%cWwgN7oX9s#kvgqfVZ(I1e%-xjZpY z2ZI=FUcLDQSZ|hmt&LDLvn!Z}hc{JwOe_Xgj|3D5aa#1B?%ca4vXIpDUo;G8 z27>0_R4cs8CB)wb9vk!r!}_O2)=(LU{W1fV|=6X8La)YC-8F&RwwkuYTED*hc15g!E(##MW? zA1UdC>I{tVGmu6=vRoQ|N@pR!1q)2esACNd{$T&;D`5UHQkvu;2SBea#tXbH<2Hh- z$s(L}G0=hMtwbyUrS8on3_HyLyTZ1EoNF=J6vt`jKPLPmvjlXVBWUk>QYw#xZ@(FJb zvnG0x@7|XL?=<*{{4UPk*OYFTQ0VwJsCBY)T&OrHYZ&(nknB~6L|hI(f1k_)=G-r_ zJfN`gWoDfaY|p!#6dmzzgEOBI0<7Z$l>hV_2pp{b+aT~immpN=MzlX$QM{^3^4^N~ zbNS>8ltz6MtI1M``|i*N9s@(vlT6z*;Yzv3X~pjw;AyhmPc9Ni2jxP9>u4JWE;eO(GMm2pELpDyZh03TMBx)B7j_F{O|W{^Jn_HS^~Fat?^ z-Oo@WU2ck76+LjZbG?`4QNUeY&uGpRSAgXL-(U`LC1>oB4~AxFy7DW+Ce8@&0r;oh zP=hhU#dNQ|>V^hJvEns0U?|PTAw9+j;!<$ z1Gbs~c^X8#6YNH02nfwPu;d5=1UcV4F-RIjILnv=DcuOcb~ua~Vc~F%wA6{?kiX zF<|$Cz=7|?;6xZjm{x{IV+QiNp%~01Z4@-fA7Si&4)O-;L~tlGhK(6q(_PsY+7mf) zJ>an)fi1jbSl`P(cQyERxM|Nb7$ZHe%erI~kle(m!@O! zfq6q@u$`|EvPeMFKsU$|P#(NY;NR>erjW+mnmuMM0v<90@!sVLrBH(x80mMd8CSsI zfH04e5P*Y8Q^?rQWPV~I9g1o_fGIRKxXbLFrZXY27`t8!MHg zUUz5)c4@qPcEB9}X8x^)YRuIoYzONk5hMl> z^ixqm?I1xGfds?87!gEe6DbJmg=-Kcj}WIh{b4M}7w8hH<0il$#E}a)?dYz%X(y56-t1#TjuztG<<#-$7$BE&s%9xp@|z1i%Yz(L zT*5NLX_38Vhy)mJ^skX4WT08Fn9nNw1Y6$EMK4JnP!joUMZGxzeA1X*?J0pVh5QAh zg*KXCZ96G|Yt8DEV;*_*EjexDZ3NFpCRet0eEMAsTcnW2YGkjdTm3ubAZT;&XRx4i z|1qFa5tt1)CYORPo+cNktUh~d^RZYsIqgQZ1z_wRtF^GW)*vU<72VL2jn-mZPRAeC zHZC98luJ>?A0w1x=bK!sBU5fFM|=GMJ@!8P>R{kXNEfkn&68LFW(qW%D(-_`j}CMPb}jV08<;623X7?Lw{c z-=HRXZgodVQft-^n`?hX^%b4I-TCTH3U`rtgoSwoyVFiplti)cIqEG!_QoL$OYujx z_>Jx}%_4)3OeOzf?;){+`3F>Ovvk?1jv?;mr6FEFG0{4f9~C6yB)&6~G~B?S`Uq70 zwFb6xHTo)(DQ^O&)W000!G__rfi)neEN%$MQssYW6D;ZK7MTZCYcwb30a{oMl-I%< zM8u^7EV%IMG}1c0a0Hl50gT{fi6!jvs`mAhg!tD$f4_FXa~*YfLe4Lb1p5pCLmx&g z%LtmwDjZwk*Cf>P(_2qSQ(fFf$(J8gkW~gT6F?dEy9+9uF*9_z{{aHC!Z9^MsSi*; zF+uhwC>;W}u)+YOrW;0J3h|-OYccBGt~WtYhJNoOmO-t>Ri(e+P6(ejZD7PejEs#k z1;$x-S*jlzntf&LysXGWfPE^~po|AezN(Y4R;@3-g#oJ@a`)q5z&rurjR2dqTH~Rg z5yqPOPwf7z+HqiSoBgyw&U4!8){(yacr419UzJr;u66IbT-DP%D5KXR0xW)QZQdhP zcA%a;z{9O#151)5#ubg}fk&Z9K3BKmTmKhCu)j{`)e9Ec{#fN)SCm>`w|(jUH($CHUx}Rs8K?KbvTg@W&%ANVJZL? z8(#chf2a81za|RKz54#_yEw-qx*~Hb!}mne-(X4H7vHs<%-pHHyMw$lxgSzqSb0vO zmvi24b>WD^ygFHu*U52dq$&#=-415{v9}Ioa3Jr^2r+Z0GvN0s1QC$}# z_)o`f=kE)~>N{;wfi_@uXbjUey#9uUH2s;uj^J`m>iS8C{Y>?C)t3D1W<2sQUSA>D zWZWYnX~ujsIrtY~)xoG(B(U4%^;S+QT^Irc(g_P(4z6xo0XXAM1jZ!9 zOKwYez7xX&Lf3cf^;R5wh;o#ZVJMfEhOYg!2Em}`3&q7pRsxd_H946LqUz7M0q@hd z=nTUars?6fQbf`XX#TFn1iDy+UUq}`DF$FFO`%mnC^4TLpx)L^E5qG}jS?9e4}LA9 zFGvIqu;&uW`If;)N66PuKC%pU)D@wx_6`C2ffwCuuLip z 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 3600d00b..fe7db329 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -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: , text: , stickers: , - effects: ( -
- Effects view coming soon... -
- ), + effects: , transitions: (
Transitions view coming soon... diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 808961f1..4c6922ff 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -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 }) => diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx new file mode 100644 index 00000000..c9b8b2dd --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx @@ -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 ( + + + + ); +} + +function EffectsGrid({ effects }: { effects: EffectDefinition[] }) { + return ( +
+ {effects.map((effect) => ( + + ))} +
+ ); +} + +function EffectPreviewCanvas({ effectType }: { effectType: string }) { + const canvasRef = useRef(null); + + useEffect(() => { + const render = () => { + if (canvasRef.current) { + effectPreviewService.renderPreview({ + effectType, + params: {}, + targetCanvas: canvasRef.current, + }); + } + }; + + render(); + return onPreviewImageReady({ callback: render }); + }, [effectType]); + + return ; +} + +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 = ; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx new file mode 100644 index 00000000..bf137291 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx @@ -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 ( + +
+ updateParam(value)} + onValueCommit={commitParam} + /> + +
+
+ ); +} + +export function EffectProperties({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const definition = getEffect({ effectType: element.effectType }); + + return ( +
+ + + + {definition.params.map((param) => ( + + ))} + + +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 4f285f84..75dd81a9 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -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 ; + } + if (element.type === "audio") { + return ; + } + if ( + element.type === "video" || + element.type === "image" || + element.type === "sticker" + ) { + return ; + } + if (element.type === "effect") { + return ; + } + return null; +} export function PropertiesPanel() { const editor = useEditor(); @@ -16,34 +44,19 @@ export function PropertiesPanel() { elements: selectedElements, }); + const hasSelection = selectedElements.length > 0; + return ( -
- {selectedElements.length > 0 ? ( +
+ {hasSelection ? ( - {elementsWithTracks.map(({ track, element }) => { - if (element.type === "text") { - return ( -
- -
- ); - } - if (element.type === "audio") { - return ; - } - if ( - element.type === "video" || - element.type === "image" || - element.type === "sticker" - ) { - return ( -
- -
- ); - } - return null; - })} + {elementsWithTracks.map(({ track, element }) => ( + + ))}
) : ( diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 18f768a9..d89d270d 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -27,7 +27,7 @@ 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() {
@@ -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({ diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 7ad63692..38cf953b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -46,6 +46,7 @@ import { Search01Icon, Exchange01Icon, KeyframeIcon, + MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; @@ -59,7 +60,6 @@ interface KeyframeIndicator { time: number; offsetPx: number; keyframes: SelectedKeyframeRef[]; - isSelected: boolean; } function buildKeyframeIndicator({ @@ -69,7 +69,6 @@ function buildKeyframeIndicator({ displayedStartTime, zoomLevel, elementLeft, - isKeyframeSelected, }: { keyframe: ElementKeyframe; trackId: string; @@ -77,16 +76,10 @@ function buildKeyframeIndicator({ displayedStartTime: number; zoomLevel: number; elementLeft: number; - isKeyframeSelected: ({ - keyframe, - }: { - keyframe: SelectedKeyframeRef; - }) => boolean; }): { time: number; offsetPx: number; keyframeRef: SelectedKeyframeRef; - isSelected: boolean; } { const keyframeRef = { trackId, @@ -102,7 +95,6 @@ function buildKeyframeIndicator({ time: keyframe.time, offsetPx: keyframeLeft - elementLeft, keyframeRef, - isSelected: isKeyframeSelected({ keyframe: keyframeRef }), }; } @@ -114,7 +106,6 @@ function getKeyframeIndicators({ zoomLevel, elementLeft, elementWidth, - isKeyframeSelected, }: { keyframes: ElementKeyframe[]; trackId: string; @@ -123,11 +114,6 @@ function getKeyframeIndicators({ zoomLevel: number; elementLeft: number; elementWidth: number; - isKeyframeSelected: ({ - keyframe, - }: { - keyframe: SelectedKeyframeRef; - }) => boolean; }): KeyframeIndicator[] { if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { return []; @@ -142,7 +128,6 @@ function getKeyframeIndicators({ displayedStartTime, zoomLevel, elementLeft, - isKeyframeSelected, }); const existingIndicator = keyframesByTime.get(indicator.time); if (!existingIndicator) { @@ -150,14 +135,11 @@ function getKeyframeIndicators({ time: indicator.time, offsetPx: indicator.offsetPx, keyframes: [indicator.keyframeRef], - isSelected: indicator.isSelected, }); continue; } existingIndicator.keyframes.push(indicator.keyframeRef); - existingIndicator.isSelected = - existingIndicator.isSelected || indicator.isSelected; } return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); @@ -187,6 +169,7 @@ interface TimelineElementProps { ) => void; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; + isDropTarget?: boolean; } export function TimelineElement({ @@ -199,10 +182,10 @@ export function TimelineElement({ onElementMouseDown, onElementClick, dragState, + isDropTarget = false, }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); - const { isKeyframeSelected } = useKeyframeSelection(); const { requestRevealMedia } = useAssetsPanelStore(); const mediaAssets = editor.media.getAssets(); @@ -248,16 +231,17 @@ export function TimelineElement({ time: displayedStartTime, zoomLevel, }); - const keyframeIndicators = getKeyframeIndicators({ - keyframes: getElementKeyframes({ animations: element.animations }), - trackId: track.id, - elementId: element.id, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, - isKeyframeSelected, - }); + 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)) { @@ -291,8 +275,9 @@ export function TimelineElement({ onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} + isDropTarget={isDropTarget} /> - + {isSelected && }
@@ -365,6 +350,7 @@ function ElementInner({ onElementClick, onElementMouseDown, handleResizeStart, + isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; @@ -382,14 +368,20 @@ function ElementInner({ elementId: string; side: "left" | "right"; }) => void; + isDropTarget?: boolean; }) { + const opacityClass = + (canElementBeHidden(element) && element.hidden) || isDropTarget + ? "opacity-50" + : ""; + return (
- )); + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + return ( + + ); + }); } -function ElementContent({ - element, - track, - isSelected, - mediaAssets, -}: { +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 ( -
- {element.content} +
+ + {textElement.content} +
); - } - - if (element.type === "sticker") { + }, + effect: ({ element }) => ( +
+ + {element.name} +
+ ), + sticker: ({ element }) => { + const stickerElement = element as Extract< + TimelineElementType, + { type: "sticker" } + >; return (
{element.name} - {element.name} + + {stickerElement.name} +
); - } - - 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 ( @@ -593,28 +614,78 @@ function ElementContent({ return ( - {element.name} + {audioElement.name} ); - } + }, + 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 ( + + {videoElement.name} + + ); + } + + if (mediaAsset.thumbnailUrl) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + return ( +
+
+
+
+
+ ); + } - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { return ( - {element.name} + {videoElement.name} ); - } + }, + 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 ( + + {imageElement.name} + + ); + } - 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 (
@@ -624,7 +695,7 @@ function ElementContent({
); - } + }, +}; - return ( - {element.name} - ); +function ElementContent(props: ElementContentProps) { + const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; + return <>{renderer(props)}; } function CopyMenuItem() { diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 42faadaf..fbc7cb3f 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -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} /> ); }) diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 311f6edb..1b57ea4d 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -40,7 +40,7 @@ const selectTriggerVariants = cva( }, size: { default: "", - sm: "", + sm: "rounded-sm", }, }, defaultVariants: { diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 57ddbcf0..700be644 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -7,7 +7,9 @@ import { cn } from "@/utils/ui"; const Slider = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( = { +export const TRACK_CONFIG: Record< + TrackType, + { + background: string; + height: number; + defaultName: string; + icon: React.ReactNode; + } +> = { video: { background: "transparent", + height: 60, + defaultName: "Video track", + icon: , }, text: { background: "bg-[#5DBAA0]", + height: 25, + defaultName: "Text track", + icon: ( + + ), }, audio: { - background: "bg-[#915DBE]", + background: "bg-[#8F5DBA]", + height: 50, + defaultName: "Audio track", + icon: ( + + ), }, sticker: { - background: "bg-amber-500", + background: "bg-[#BA5D7A]", + height: 50, + defaultName: "Sticker track", + icon: ( + + ), + }, + effect: { + background: "bg-[#5d93ba]", + height: 25, + defaultName: "Effect track", + icon: ( + + ), }, -} as const; - -export const TRACK_HEIGHTS: Record = { - 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 = { - video: , - text: ( - - ), - audio: ( - - ), - sticker: ( - - ), -} as const; diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 4a8813f8..42ab3b87 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -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); diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 2b303ad9..2340c9b2 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -86,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 }) => { diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts index 12c6bf8a..c4f8d6ce 100644 --- a/apps/web/src/hooks/timeline/use-selection-box.ts +++ b/apps/web/src/hooks/timeline/use-selection-box.ts @@ -5,6 +5,7 @@ import { useEditor } from "../use-editor"; interface UseSelectionBoxProps { containerRef: React.RefObject; + headerRef: React.RefObject; 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(() => { diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 632d2c78..ee739298 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -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; @@ -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, diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts new file mode 100644 index 00000000..a84781e0 --- /dev/null +++ b/apps/web/src/hooks/use-effect-preview.ts @@ -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; + isActive: boolean; +}): void { + const requestRef = useRef(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]); +} diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts index a8f60643..d3f68010 100644 --- a/apps/web/src/lib/animation/interpolation.ts +++ b/apps/web/src/lib/animation/interpolation.ts @@ -217,6 +217,11 @@ function evaluateChannelValueAtTime { const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + if (!Number.isFinite(boundedTime)) return element; return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl new file mode 100644 index 00000000..2c015725 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl @@ -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; +} diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts new file mode 100644 index 00000000..3a8f691a --- /dev/null +++ b/apps/web/src/lib/effects/definitions/blur.ts @@ -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], + }; + }, + }, + ], + }, +}; diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts new file mode 100644 index 00000000..1742b1d7 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/index.ts @@ -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 }); + } +} diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl new file mode 100644 index 00000000..5febc835 --- /dev/null +++ b/apps/web/src/lib/effects/effect.vert.glsl @@ -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); +} diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts new file mode 100644 index 00000000..14d06d84 --- /dev/null +++ b/apps/web/src/lib/effects/index.ts @@ -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, + }; +} diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts new file mode 100644 index 00000000..568b8924 --- /dev/null +++ b/apps/web/src/lib/effects/registry.ts @@ -0,0 +1,31 @@ +import type { EffectDefinition } from "@/types/effects"; + +const effectDefinitions = new Map(); + +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()); +} diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index cc63048f..5b1f301d 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -70,7 +70,7 @@ export function getElementBounds({ 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; diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index 9182892f..b502974d 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -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; diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 9e191833..8c02c097 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -6,6 +6,7 @@ import { TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import type { + CreateEffectElement, CreateTimelineElement, CreateVideoElement, CreateImageElement, @@ -22,6 +23,8 @@ import type { 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, @@ -155,13 +158,13 @@ export function buildTextElement({ fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, color: t.color ?? DEFAULT_TEXT_ELEMENT.color, 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, - }, + 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, @@ -174,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, @@ -218,6 +243,7 @@ export function buildVideoElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, muted: false, hidden: false, transform: { ...DEFAULT_TRANSFORM }, @@ -274,6 +300,7 @@ export function buildUploadAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; @@ -336,6 +363,7 @@ export function buildLibraryAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index 415d0ace..94ff21ba 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -6,11 +6,11 @@ import type { AudioTrack, StickerTrack, TextTrack, + EffectTrack, TimelineElement, } from "@/types/timeline"; import { - TRACK_COLORS, - TRACK_HEIGHTS, + TRACK_CONFIG, TRACK_GAP, } from "@/constants/timeline-constants"; import { generateUUID } from "@/utils/id"; @@ -23,21 +23,20 @@ export function canTracktHaveAudio( export function canTrackBeHidden( track: TimelineTrack, -): track is VideoTrack | TextTrack | StickerTrack { +): track is VideoTrack | TextTrack | StickerTrack | EffectTrack { return track.type !== "audio"; } export function getTrackColor({ type }: { type: TrackType }) { - return TRACK_COLORS[type]; + return TRACK_CONFIG[type]; } export function getTrackClasses({ type }: { type: TrackType }) { - const colors = TRACK_COLORS[type]; - return `${colors.background}`.trim(); + return TRACK_CONFIG[type].background.trim(); } export function getTrackHeight({ type }: { type: TrackType }): number { - return TRACK_HEIGHTS[type]; + return TRACK_CONFIG[type].height; } export function getCumulativeHeightBefore({ @@ -77,17 +76,7 @@ export function buildEmptyTrack({ type: TrackType; name?: string; }): TimelineTrack { - const trackName = - name ?? - (type === "video" - ? "Video track" - : type === "text" - ? "Text track" - : type === "audio" - ? "Audio track" - : type === "sticker" - ? "Sticker track" - : "Track"); + const trackName = name ?? TRACK_CONFIG[type].defaultName; switch (type) { case "video": @@ -124,6 +113,14 @@ export function buildEmptyTrack({ elements: [], muted: false, }; + case "effect": + return { + id, + name: trackName, + type: "effect", + elements: [], + hidden: false, + }; default: throw new Error(`Unsupported track type: ${type}`); } @@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({ return tracks.length; } + if (trackType === "effect") { + return 0; + } + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); if (mainTrackIndex >= 0) { return mainTrackIndex; @@ -216,6 +217,7 @@ export function canElementGoOnTrack({ 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"; } diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts new file mode 100644 index 00000000..093168e6 --- /dev/null +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -0,0 +1,16 @@ +export function createOffscreenCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + try { + return new OffscreenCanvas(width, height); + } catch { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } +} diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts new file mode 100644 index 00000000..2cebec5d --- /dev/null +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -0,0 +1,194 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +const PREVIEW_SIZE = 160; +const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; + +let previewGl: WebGLRenderingContext | null = null; +let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let previewImageElement: HTMLImageElement | null = null; +const programCache = new Map(); +const onReadyCallbacks = new Set<() => void>(); + +export function onPreviewImageReady({ + callback, +}: { + callback: () => void; +}): () => void { + onReadyCallbacks.add(callback); + return () => onReadyCallbacks.delete(callback); +} + +function loadPreviewImage(): void { + if (typeof window === "undefined") return; + const image = new Image(); + image.onload = () => { + testSourceCanvas = null; + for (const callback of onReadyCallbacks) { + callback(); + } + }; + image.src = PREVIEW_IMAGE_PATH; + previewImageElement = image; +} + +loadPreviewImage(); + +function buildDefaultParams({ + effectType, +}: { + effectType: string; +}): EffectParamValues { + const definition = getEffect({ effectType }); + const params: EffectParamValues = {}; + for (const paramDef of definition.params) { + params[paramDef.key] = paramDef.default; + } + return params; +} + +function createTestSource({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement | null { + const isImageReady = + previewImageElement?.complete && + (previewImageElement.naturalWidth ?? 0) > 0; + if (!isImageReady || !previewImageElement) { + return null; + } + + const canvas = createOffscreenCanvas({ width, height }); + const ctx = canvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!ctx) { + throw new Error("failed to get 2d context for test source"); + } + ctx.drawImage(previewImageElement, 0, 0, width, height); + return canvas; +} + +function getOrCreatePreviewContext({ + width, + height, +}: { + width: number; + height: number; +}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } { + if (!previewCanvas || !previewGl) { + previewCanvas = createOffscreenCanvas({ width, height }); + previewGl = previewCanvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!previewGl) { + throw new Error("WebGL not supported"); + } + } + if (previewCanvas.width !== width || previewCanvas.height !== height) { + previewCanvas.width = width; + previewCanvas.height = height; + } + return { canvas: previewCanvas, gl: previewGl }; +} + +function getTestSource({ + width, + height, +}: { + width: number; + height: number; +}): CanvasImageSource | null { + if ( + !testSourceCanvas || + testSourceCanvas.width !== width || + testSourceCanvas.height !== height + ) { + testSourceCanvas = createTestSource({ width, height }); + } + return testSourceCanvas; +} + +function applyWebGlEffect({ + source, + width, + height, + passes, +}: { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +}): OffscreenCanvas | HTMLCanvasElement { + const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height }); + + applyMultiPassEffect({ context: gl, source, width, height, passes, programCache }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(glCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export function renderPreview({ + effectType, + params, + targetCanvas, +}: { + effectType: string; + params: EffectParamValues; + targetCanvas: HTMLCanvasElement; +}): void { + const size = PREVIEW_SIZE; + const source = getTestSource({ width: size, height: size }); + if (!source) return; + + const definition = getEffect({ effectType }); + const resolvedParams = + Object.keys(params).length > 0 + ? params + : buildDefaultParams({ effectType }); + + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: resolvedParams, + width: size, + height: size, + }), + })); + const result = applyWebGlEffect({ + source, + width: size, + height: size, + passes, + }); + + const targetCtx = targetCanvas.getContext( + "2d", + ) as CanvasRenderingContext2D | null; + if (targetCtx) { + targetCanvas.width = size; + targetCanvas.height = size; + targetCtx.drawImage(result, 0, 0, size, size); + } +} + +export const effectPreviewService = { + renderPreview, + onPreviewImageReady, + PREVIEW_SIZE, +}; diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts deleted file mode 100644 index 504161d9..00000000 --- a/apps/web/src/services/renderer/nodes/blur-background-node.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode } from "./base-node"; - -export type BlurBackgroundNodeParams = { - blurIntensity: number; - contentNodes: BaseNode[]; -}; - -export class BlurBackgroundNode extends BaseNode { - private blurIntensity: number; - private contentNodes: BaseNode[]; - - constructor(params: BlurBackgroundNodeParams) { - super(params); - this.blurIntensity = params.blurIntensity; - this.contentNodes = params.contentNodes; - } - - async render({ - renderer, - time, - }: { - renderer: CanvasRenderer; - time: number; - }): Promise { - let offscreen: OffscreenCanvas | HTMLCanvasElement; - let offscreenCtx: - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; - - try { - offscreen = new OffscreenCanvas(renderer.width, renderer.height); - const ctx = offscreen.getContext("2d"); - if (!ctx) { - throw new Error("failed to get offscreen canvas context"); - } - offscreenCtx = ctx; - } catch { - offscreen = document.createElement("canvas"); - offscreen.width = renderer.width; - offscreen.height = renderer.height; - const ctx = offscreen.getContext("2d"); - if (!ctx) { - throw new Error("failed to get canvas context"); - } - offscreenCtx = ctx; - } - - const originalContext = renderer.context; - renderer.context = offscreenCtx; - - for (const node of this.contentNodes) { - await node.render({ renderer, time }); - } - - renderer.context = originalContext; - - const zoomScale = 1.4; - const scaledWidth = renderer.width * zoomScale; - const scaledHeight = renderer.height * zoomScale; - const offsetX = (renderer.width - scaledWidth) / 2; - const offsetY = (renderer.height - scaledHeight) / 2; - - renderer.context.save(); - renderer.context.filter = `blur(${this.blurIntensity}px)`; - renderer.context.drawImage( - offscreen as CanvasImageSource, - offsetX, - offsetY, - scaledWidth, - scaledHeight, - ); - renderer.context.restore(); - } -} diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts new file mode 100644 index 00000000..3fbfffc1 --- /dev/null +++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts @@ -0,0 +1,77 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +export type CompositeEffectNodeParams = { + contentNodes: BaseNode[]; + effectType: string; + effectParams: EffectParamValues; + scale: number; +}; + +export class CompositeEffectNode extends BaseNode { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + const offscreen = createOffscreenCanvas({ + width: renderer.width, + height: renderer.height, + }); + const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null; + if (!offscreenCtx) { + throw new Error("failed to get offscreen canvas context"); + } + + const originalContext = renderer.context; + renderer.context = offscreenCtx; + + for (const node of this.params.contentNodes) { + await node.render({ renderer, time }); + } + + renderer.context = originalContext; + + const effectDefinition = getEffect({ effectType: this.params.effectType }); + const scale = this.params.scale; + const scaledWidth = renderer.width * scale; + const scaledHeight = renderer.height * scale; + const offsetX = (renderer.width - scaledWidth) / 2; + const offsetY = (renderer.height - scaledHeight) / 2; + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source: offscreen as CanvasImageSource, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + offsetX, + offsetY, + scaledWidth, + scaledHeight, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts new file mode 100644 index 00000000..3d84be2b --- /dev/null +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -0,0 +1,82 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +const TIME_EPSILON = 1e-6; + +export type EffectLayerNodeParams = { + effectType: string; + effectParams: EffectParamValues; + timeOffset: number; + duration: number; +}; + +function isInRange({ + time, + timeOffset, + duration, +}: { + time: number; + timeOffset: number; + duration: number; +}): boolean { + return ( + time >= timeOffset - TIME_EPSILON && + time < timeOffset + duration + TIME_EPSILON + ); +} + +// snapshots whatever is currently on the canvas, applies the effect, draws it back +export class EffectLayerNode extends BaseNode { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + if ( + !isInRange({ + time, + timeOffset: this.params.timeOffset, + duration: this.params.duration, + }) + ) { + return; + } + + const source = renderer.context.canvas as CanvasImageSource; + + const effectDefinition = getEffect({ + effectType: this.params.effectType, + }); + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.clearRect(0, 0, renderer.width, renderer.height); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 6f4f2bd8..6720a5e5 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -73,7 +73,7 @@ export class ImageNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index 86f14fb4..da271685 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -51,7 +51,7 @@ export class StickerNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 9ae63661..503c9c03 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -12,11 +12,11 @@ export class VideoNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } - const videoTime = this.getSourceLocalTime(time); + const videoTime = this.getSourceLocalTime({ time }); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 50451b4a..15a7b081 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,5 +1,7 @@ import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; +import type { Effect } from "@/types/effects"; import type { BlendMode } from "@/types/rendering"; import type { Transform } from "@/types/timeline"; import type { ElementAnimations } from "@/types/animation"; @@ -9,6 +11,8 @@ import { resolveTransformAtTime, } from "@/lib/animation"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { getEffect } from "@/lib/effects"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; export interface VisualNodeParams { duration: number; @@ -19,16 +23,17 @@ export interface VisualNodeParams { animations?: ElementAnimations; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export abstract class VisualNode< Params extends VisualNodeParams = VisualNodeParams, > extends BaseNode { - protected getSourceLocalTime(time: number): number { + protected getSourceLocalTime({ time }: { time: number }): number { return time - this.params.timeOffset + this.params.trimStart; } - protected getAnimationLocalTime(time: number): number { + protected getAnimationLocalTime({ time }: { time: number }): number { return getElementLocalTime({ timelineTime: time, elementStartTime: this.params.timeOffset, @@ -36,8 +41,8 @@ export abstract class VisualNode< }); } - protected isInRange(time: number): boolean { - const localTime = this.getSourceLocalTime(time); + protected isInRange({ time }: { time: number }): boolean { + const localTime = this.getSourceLocalTime({ time }); return ( localTime >= this.params.trimStart - TIME_EPSILON_SECONDS && localTime < this.params.trimStart + this.params.duration @@ -59,7 +64,7 @@ export abstract class VisualNode< }): void { renderer.context.save(); - const animationLocalTime = this.getAnimationLocalTime(timelineTime); + const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime }); const transform = resolveTransformAtTime({ baseTransform: this.params.transform, animations: this.params.animations, @@ -94,7 +99,58 @@ export abstract class VisualNode< renderer.context.translate(-centerX, -centerY); } - renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + const enabledEffects = + this.params.effects?.filter((effect) => effect.enabled) ?? []; + + if (enabledEffects.length === 0) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + const elementCanvas = createOffscreenCanvas({ + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + }); + const elementCtx = elementCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!elementCtx) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight); + + let currentResult: CanvasImageSource = elementCanvas; + + for (const effect of enabledEffects) { + const definition = getEffect({ effectType: effect.type }); + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: effect.params, + width: scaledWidth, + height: scaledHeight, + }), + })); + currentResult = webglEffectRenderer.applyEffect({ + source: currentResult, + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + passes, + }); + } + + renderer.context.drawImage( + currentResult, + x, + y, + scaledWidth, + scaledHeight, + ); renderer.context.restore(); } } diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 2bbcf25f..695e077f 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -6,12 +6,137 @@ import { ImageNode } from "./nodes/image-node"; import { TextNode } from "./nodes/text-node"; import { StickerNode } from "./nodes/sticker-node"; import { ColorNode } from "./nodes/color-node"; -import { BlurBackgroundNode } from "./nodes/blur-background-node"; +import { CompositeEffectNode } from "./nodes/composite-effect-node"; +import { EffectLayerNode } from "./nodes/effect-layer-node"; +import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/types/project"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { isMainTrack } from "@/lib/timeline"; const PREVIEW_MAX_IMAGE_SIZE = 2048; +const BLUR_BACKGROUND_ZOOM_SCALE = 1.4; + +function getVisibleSortedElements({ + track, +}: { + track: TimelineTrack; +}) { + return track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); +} + +function buildTrackNodes({ + tracks, + mediaMap, + canvasSize, + isPreview, +}: { + tracks: TimelineTrack[]; + mediaMap: Map; + canvasSize: TCanvasSize; + isPreview?: boolean; +}): BaseNode[] { + const nodes: BaseNode[] = []; + + for (const track of tracks) { + const elements = getVisibleSortedElements({ track }); + + for (const element of elements) { + if (element.type === "effect") { + nodes.push( + new EffectLayerNode({ + effectType: element.effectType, + effectParams: element.params, + timeOffset: element.startTime, + duration: element.duration, + }), + ); + continue; + } + + if (element.type === "video" || element.type === "image") { + const mediaAsset = mediaMap.get(element.mediaId); + if (!mediaAsset?.file || !mediaAsset?.url) { + continue; + } + + if (mediaAsset.type === "video") { + nodes.push( + new VideoNode({ + mediaId: mediaAsset.id, + url: mediaAsset.url, + file: mediaAsset.file, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + }), + ); + } + if (mediaAsset.type === "image") { + nodes.push( + new ImageNode({ + url: mediaAsset.url, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + ...(isPreview && { + maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, + }), + }), + ); + } + } + + if (element.type === "text") { + nodes.push( + new TextNode({ + ...element, + canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, + canvasHeight: canvasSize.height, + textBaseline: "middle", + effects: element.effects, + }), + ); + } + + if (element.type === "sticker") { + nodes.push( + new StickerNode({ + stickerId: element.stickerId, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + }), + ); + } + } + } + + return nodes; +} export type BuildSceneParams = { canvasSize: TCanvasSize; @@ -22,9 +147,14 @@ export type BuildSceneParams = { isPreview?: boolean; }; -export function buildScene(params: BuildSceneParams) { - const { tracks, mediaAssets, duration, canvasSize, background } = params; - +export function buildScene({ + canvasSize, + tracks, + mediaAssets, + duration, + background, + isPreview, +}: BuildSceneParams) { const rootNode = new RootNode({ duration }); const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); @@ -39,107 +169,33 @@ export function buildScene(params: BuildSceneParams) { const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); - const contentNodes = []; - - for (const track of orderedTracksBottomToTop) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); - - for (const element of elements) { - if (element.type === "video" || element.type === "image") { - const mediaAsset = mediaMap.get(element.mediaId); - if (!mediaAsset?.file || !mediaAsset?.url) { - continue; - } - - if (mediaAsset.type === "video") { - contentNodes.push( - new VideoNode({ - mediaId: mediaAsset.id, - url: mediaAsset.url, - file: mediaAsset.file, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - }), - ); - } - if (mediaAsset.type === "image") { - contentNodes.push( - new ImageNode({ - url: mediaAsset.url, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - ...(params.isPreview && { - maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, - }), - }), - ); - } - } - - if (element.type === "text") { - contentNodes.push( - new TextNode({ - ...element, - canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, - canvasHeight: canvasSize.height, - textBaseline: "middle", - }), - ); - } - - if (element.type === "sticker") { - contentNodes.push( - new StickerNode({ - stickerId: element.stickerId, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - }), - ); - } - } - } + const allNodes = buildTrackNodes({ + tracks: orderedTracksBottomToTop, + mediaMap, + canvasSize, + isPreview, + }); if (background.type === "blur") { rootNode.add( - new BlurBackgroundNode({ - blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, - contentNodes, + new CompositeEffectNode({ + contentNodes: allNodes.filter( + (node) => !(node instanceof EffectLayerNode), + ), + effectType: "blur", + effectParams: { + intensity: + background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, + }, + scale: BLUR_BACKGROUND_ZOOM_SCALE, }), ); - for (const node of contentNodes) { - rootNode.add(node); - } - } else { - if (background.type === "color" && background.color !== "transparent") { - rootNode.add(new ColorNode({ color: background.color })); - } - for (const node of contentNodes) { - rootNode.add(node); - } + } else if (background.type === "color" && background.color !== "transparent") { + rootNode.add(new ColorNode({ color: background.color })); + } + + for (const node of allNodes) { + rootNode.add(node); } return rootNode; diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts new file mode 100644 index 00000000..ad5cd40e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-effect-renderer.ts @@ -0,0 +1,73 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +export interface ApplyEffectParams { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +} + +let gl: WebGLRenderingContext | null = null; +let canvas: OffscreenCanvas | HTMLCanvasElement | null = null; +const programCache = new Map(); + +function getOrCreateCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + if (!canvas) { + canvas = createOffscreenCanvas({ width, height }); + gl = canvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!gl) { + throw new Error("WebGL not supported"); + } + } + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + return canvas; +} + +function applyEffect({ + source, + width, + height, + passes, +}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement { + const targetCanvas = getOrCreateCanvas({ width, height }); + const context = gl; + if (!context) { + throw new Error("WebGL context not initialized"); + } + + applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, + }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(targetCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export const webglEffectRenderer = { + applyEffect, +}; diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts new file mode 100644 index 00000000..834c652e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-utils.ts @@ -0,0 +1,298 @@ +import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl"; + +export interface EffectPassData { + fragmentShader: string; + uniforms: Record; +} + +export const QUAD_POSITIONS = new Float32Array([ + -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, +]); + +export function compileProgram({ + context, + fragmentShaderSource, + programCache, +}: { + context: WebGLRenderingContext; + fragmentShaderSource: string; + programCache: Map; +}): WebGLProgram { + const cached = programCache.get(fragmentShaderSource); + if (cached) { + return cached; + } + const vertexShader = compileShader({ + context, + source: VERTEX_SHADER_SOURCE, + type: context.VERTEX_SHADER, + }); + const fragmentShader = compileShader({ + context, + source: fragmentShaderSource, + type: context.FRAGMENT_SHADER, + }); + const program = context.createProgram(); + if (!program) { + throw new Error("Failed to create WebGL program"); + } + context.attachShader(program, vertexShader); + context.attachShader(program, fragmentShader); + context.linkProgram(program); + if (!context.getProgramParameter(program, context.LINK_STATUS)) { + const info = context.getProgramInfoLog(program); + context.deleteProgram(program); + throw new Error(`WebGL program link failed: ${info}`); + } + context.deleteShader(vertexShader); + context.deleteShader(fragmentShader); + programCache.set(fragmentShaderSource, program); + return program; +} + +export function compileShader({ + context, + source, + type, +}: { + context: WebGLRenderingContext; + source: string; + type: number; +}): WebGLShader { + const shader = context.createShader(type); + if (!shader) { + throw new Error("Failed to create WebGL shader"); + } + context.shaderSource(shader, source); + context.compileShader(shader); + if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) { + const info = context.getShaderInfoLog(shader); + context.deleteShader(shader); + throw new Error(`WebGL shader compile failed: ${info}`); + } + return shader; +} + +export function createTexture({ + context, + source, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; +}): WebGLTexture { + const texture = context.createTexture(); + if (!texture) { + throw new Error("Failed to create WebGL texture"); + } + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, texture); + context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + context.RGBA, + context.UNSIGNED_BYTE, + source as TexImageSource, + ); + return texture; +} + +export function setUniforms({ + context, + program, + uniforms, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + uniforms: Record; +}): void { + for (const [name, value] of Object.entries(uniforms)) { + const location = context.getUniformLocation(program, name); + if (location === null) continue; + + if (typeof value === "number") { + context.uniform1f(location, value); + } else if (Array.isArray(value)) { + if (value.length === 2) { + context.uniform2fv(location, new Float32Array(value)); + } else if (value.length === 3) { + context.uniform3fv(location, new Float32Array(value)); + } else if (value.length === 4) { + context.uniform4fv(location, new Float32Array(value)); + } + } + } +} + +export function drawFullscreenQuad({ + context, + program, + width, + height, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + width: number; + height: number; +}): void { + const positionLocation = context.getAttribLocation(program, "a_position"); + const buffer = context.createBuffer(); + context.bindBuffer(context.ARRAY_BUFFER, buffer); + context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW); + context.enableVertexAttribArray(positionLocation); + context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0); + + context.viewport(0, 0, width, height); + context.clearColor(0, 0, 0, 0); + context.clear(context.COLOR_BUFFER_BIT); + context.drawArrays(context.TRIANGLES, 0, 6); +} + +export function createFramebufferTexture({ + context, + width, + height, +}: { + context: WebGLRenderingContext; + width: number; + height: number; +}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } { + const texture = context.createTexture(); + if (!texture) throw new Error("Failed to create framebuffer texture"); + context.bindTexture(context.TEXTURE_2D, texture); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + width, + height, + 0, + context.RGBA, + context.UNSIGNED_BYTE, + null, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.bindTexture(context.TEXTURE_2D, null); + + const framebuffer = context.createFramebuffer(); + if (!framebuffer) throw new Error("Failed to create framebuffer"); + context.bindFramebuffer(context.FRAMEBUFFER, framebuffer); + context.framebufferTexture2D( + context.FRAMEBUFFER, + context.COLOR_ATTACHMENT0, + context.TEXTURE_2D, + texture, + 0, + ); + context.bindFramebuffer(context.FRAMEBUFFER, null); + + return { texture, framebuffer }; +} + +export function applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; + programCache: Map; +}): void { + const sourceTexture = createTexture({ context, source }); + let currentTexture: WebGLTexture = sourceTexture; + + const intermediates: Array<{ + texture: WebGLTexture; + framebuffer: WebGLFramebuffer; + }> = []; + for (let i = 0; i < passes.length - 1; i++) { + intermediates.push(createFramebufferTexture({ context, width, height })); + } + + for (let i = 0; i < passes.length; i++) { + const pass = passes[i]; + const program = compileProgram({ + context, + fragmentShaderSource: pass.fragmentShader, + programCache, + }); + const isLastPass = i === passes.length - 1; + const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer; + + context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer); + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook + context.useProgram(program); + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, currentTexture); + + const uTextureLocation = context.getUniformLocation(program, "u_texture"); + if (uTextureLocation) { + context.uniform1i(uTextureLocation, 0); + } + + setUniforms({ + context, + program, + uniforms: { ...pass.uniforms, u_resolution: [width, height] }, + }); + drawFullscreenQuad({ context, program, width, height }); + + if (!isLastPass) { + currentTexture = intermediates[i].texture; + } + } + + context.deleteTexture(sourceTexture); + for (const intermediate of intermediates) { + context.deleteTexture(intermediate.texture); + context.deleteFramebuffer(intermediate.framebuffer); + } + context.bindTexture(context.TEXTURE_2D, null); + context.bindFramebuffer(context.FRAMEBUFFER, null); +} diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index fd40f14c..86a202b2 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -6,10 +6,11 @@ import { V3toV4Migration } from "./v3-to-v4"; import { V4toV5Migration } from "./v4-to-v5"; import { V5toV6Migration } from "./v5-to-v6"; import { V6toV7Migration } from "./v6-to-v7"; +import { V7toV8Migration } from "./v7-to-v8"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 7; +export const CURRENT_PROJECT_VERSION = 8; export const migrations = [ new V0toV1Migration(), @@ -19,4 +20,5 @@ export const migrations = [ new V4toV5Migration(), new V5toV6Migration(), new V6toV7Migration(), + new V7toV8Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts new file mode 100644 index 00000000..7393f776 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts @@ -0,0 +1,96 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV7ToV8({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV8Project({ project })) { + return { project, skipped: true, reason: "already v8" }; + } + + const migratedProject = migrateProjectElements({ project }); + + return { + project: { ...migratedProject, version: 8 }, + skipped: false, + }; +} + +function migrateProjectElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "video" && element.type !== "audio") return element; + if (typeof element.sourceDuration === "number") return element; + + const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0; + const duration = typeof element.duration === "number" ? element.duration : 0; + const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0; + + return { + ...element, + sourceDuration: trimStart + duration + trimEnd, + }; +} + +function isV8Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 8; +} diff --git a/apps/web/src/services/storage/migrations/v7-to-v8.ts b/apps/web/src/services/storage/migrations/v7-to-v8.ts new file mode 100644 index 00000000..33372c0a --- /dev/null +++ b/apps/web/src/services/storage/migrations/v7-to-v8.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV7ToV8 } from "./transformers/v7-to-v8"; + +export class V7toV8Migration extends StorageMigration { + from = 7; + to = 8; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV7ToV8({ project }); + } +} diff --git a/apps/web/src/types/drag.ts b/apps/web/src/types/drag.ts index 61aba9b5..4e10d767 100644 --- a/apps/web/src/types/drag.ts +++ b/apps/web/src/types/drag.ts @@ -1,3 +1,5 @@ +import type { VisualElement } from "./timeline"; + interface BaseDragData { id: string; name: string; @@ -6,6 +8,7 @@ interface BaseDragData { export interface MediaDragData extends BaseDragData { type: "media"; mediaType: "image" | "video" | "audio"; + targetElementTypes?: ("video" | "image")[]; } export interface TextDragData extends BaseDragData { @@ -18,4 +21,14 @@ export interface StickerDragData extends BaseDragData { stickerId: string; } -export type TimelineDragData = MediaDragData | TextDragData | StickerDragData; +export interface EffectDragData extends BaseDragData { + type: "effect"; + effectType: string; + targetElementTypes: VisualElement["type"][]; +} + +export type TimelineDragData = + | MediaDragData + | TextDragData + | StickerDragData + | EffectDragData; diff --git a/apps/web/src/types/effects.ts b/apps/web/src/types/effects.ts new file mode 100644 index 00000000..cc369450 --- /dev/null +++ b/apps/web/src/types/effects.ts @@ -0,0 +1,45 @@ +export interface Effect { + id: string; + type: string; + params: EffectParamValues; + enabled: boolean; +} + +export type EffectParamType = "number" | "boolean" | "select" | "color"; + +export type EffectParamValues = Record; + +export interface EffectParamDefinition { + key: string; + label: string; + type: EffectParamType; + default: number | string | boolean; + min?: number; + max?: number; + step?: number; + options?: Array<{ value: string; label: string }>; +} + +export interface WebGLEffectPass { + fragmentShader: string; + uniforms(params: { + effectParams: EffectParamValues; + width: number; + height: number; + }): Record; +} + +export interface WebGLEffectRenderer { + type: "webgl"; + passes: WebGLEffectPass[]; +} + +export type EffectRenderer = WebGLEffectRenderer; + +export interface EffectDefinition { + type: string; + name: string; + keywords: string[]; + params: EffectParamDefinition[]; + renderer: EffectRenderer; +} diff --git a/apps/web/src/types/glsl.d.ts b/apps/web/src/types/glsl.d.ts new file mode 100644 index 00000000..4e93dbe5 --- /dev/null +++ b/apps/web/src/types/glsl.d.ts @@ -0,0 +1,4 @@ +declare module "*.glsl" { + const value: string; + export default value; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 26690458..0c138f56 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,5 +1,6 @@ -import type { BlendMode, Transform } from "./rendering"; import type { ElementAnimations } from "./animation"; +import type { Effect, EffectParamValues } from "./effects"; +import type { BlendMode, Transform } from "./rendering"; export interface Bookmark { time: number; @@ -18,7 +19,7 @@ export interface TScene { updatedAt: Date; } -export type TrackType = "video" | "text" | "audio" | "sticker"; +export type TrackType = "video" | "text" | "audio" | "sticker" | "effect"; interface BaseTrack { id: string; @@ -51,7 +52,18 @@ export interface StickerTrack extends BaseTrack { hidden: boolean; } -export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack; +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; +} + +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | StickerTrack + | EffectTrack; export type { Transform } from "./rendering"; @@ -81,6 +93,7 @@ interface BaseTimelineElement { startTime: number; trimStart: number; trimEnd: number; + sourceDuration?: number; animations?: ElementAnimations; } @@ -92,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface ImageElement extends BaseTimelineElement { @@ -101,6 +115,7 @@ export interface ImageElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface TextElement extends BaseTimelineElement { @@ -127,6 +142,7 @@ export interface TextElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface StickerElement extends BaseTimelineElement { @@ -136,6 +152,13 @@ export interface StickerElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface EffectElement extends BaseTimelineElement { + type: "effect"; + effectType: string; + params: EffectParamValues; } export type VisualElement = @@ -154,7 +177,8 @@ export type TimelineElement = | VideoElement | ImageElement | TextElement - | StickerElement; + | StickerElement + | EffectElement; export type ElementType = TimelineElement["type"]; @@ -167,12 +191,14 @@ export type CreateVideoElement = Omit; export type CreateImageElement = Omit; export type CreateTextElement = Omit; export type CreateStickerElement = Omit; +export type CreateEffectElement = Omit; export type CreateTimelineElement = | CreateAudioElement | CreateVideoElement | CreateImageElement | CreateTextElement - | CreateStickerElement; + | CreateStickerElement + | CreateEffectElement; export interface ElementDragState { isDragging: boolean; @@ -191,6 +217,7 @@ export interface DropTarget { isNewTrack: boolean; insertPosition: "above" | "below" | null; xPosition: number; + targetElement: { elementId: string; trackId: string } | null; } export interface ComputeDropTargetParams { @@ -206,6 +233,7 @@ export interface ComputeDropTargetParams { verticalDragDirection?: "up" | "down" | null; startTimeOverride?: number; excludeElementId?: string; + targetElementTypes?: string[]; } export interface ClipboardItem { diff --git a/bun.lock b/bun.lock index 6c46fcb9..371fa28f 100644 --- a/bun.lock +++ b/bun.lock @@ -101,6 +101,7 @@ "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", @@ -300,6 +301,8 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], @@ -560,12 +563,18 @@ "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -592,6 +601,50 @@ "@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], @@ -604,10 +657,14 @@ "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "botid": ["botid@1.4.2", "", { "peerDependencies": { "next": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["next"] }, "sha512-yiRWEdxXa5QhxzJW4lTk0lRZkbqPsVWdGrhnHLLihZf0xBEtsTUGtxLqK++IY80FX/Ye/rNMnGqBp2pl4yYU8w=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], @@ -626,6 +683,8 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], @@ -638,6 +697,8 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "country-flag-icons": ["country-flag-icons@1.5.19", "", {}, "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], @@ -708,12 +769,16 @@ "drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -722,22 +787,40 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="], "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], @@ -752,6 +835,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -762,6 +847,8 @@ "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -810,14 +897,22 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], "libphonenumber-js": ["libphonenumber-js@1.12.10", "", {}, "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ=="], @@ -844,6 +939,10 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], + + "loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="], @@ -882,6 +981,8 @@ "mediabunny": ["mediabunny@1.29.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-RrlKs69MxRGa/l9cMGeI4hzuTSgchOGFHk9lIAuu9EcbSdJ05gDbRsTY67dojAiyUP3ic4PExij5OqDxqSv82Q=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -924,6 +1025,10 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], @@ -942,10 +1047,14 @@ "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], @@ -1002,10 +1111,16 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "raw-loader": ["raw-loader@4.0.2", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], @@ -1058,22 +1173,30 @@ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -1104,16 +1227,22 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.3.16", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1158,6 +1287,10 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-deep-compare-effect": ["use-deep-compare-effect@1.8.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "dequal": "^2.0.2" }, "peerDependencies": { "react": ">=16.13" } }, "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q=="], @@ -1178,10 +1311,16 @@ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="], + "wavesurfer.js": ["wavesurfer.js@7.10.0", "", {}, "sha512-GiyAHdorqGtUYG5fe4BfTf5lmtSLhrXoHeNlMsR80JOiOZxOrIOv9QaIR8RnqlleJ6D8R9cqvZKR9lfJcWcapg=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "webpack": ["webpack@5.105.3", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg=="], + + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], @@ -1310,12 +1449,20 @@ "@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="], + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + "cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "enhanced-resolve/tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -1338,8 +1485,14 @@ "recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + "webpack/enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1450,6 +1603,8 @@ "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], @@ -1464,6 +1619,10 @@ "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], @@ -1475,5 +1634,13 @@ "vaul/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "webpack/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], } } diff --git a/docs/effects-renderer.md b/docs/effects-renderer.md new file mode 100644 index 00000000..fd41282b --- /dev/null +++ b/docs/effects-renderer.md @@ -0,0 +1,64 @@ +# Effects & WebGL Renderer + +## How to add a new effect + +1. Create a new file in `apps/web/src/lib/effects/definitions/` (e.g. `brightness.ts`) +2. Export an `EffectDefinition` — see `blur.ts` as a reference +3. Register it in `apps/web/src/lib/effects/definitions/index.ts` + +An effect definition has: +- `type` — unique string identifier +- `name` — display name +- `keywords` — for search +- `params` — user-facing controls (sliders, toggles, etc.) +- `renderer` — always `webgl` + +All effects use WebGL. Even simple single-value effects like brightness or contrast are trivial shaders — there's no reason to leave the GPU pipeline for them. + +## Single-pass vs multi-pass + +The `webgl` renderer supports a `passes` array. Single-pass effects (e.g. color grading) just have one entry. Multi-pass is needed when an effect has to process its own output — blur (H then V), bloom (extract → blur → composite), glow, etc. + +```typescript +renderer: { + type: "webgl", + passes: [ + { fragmentShader: myShader, uniforms: ({ effectParams }) => ({ ... }) }, + ], +} +``` + +All WebGL rendering — both the main renderer and the effect preview — goes through `applyMultiPassEffect` in `apps/web/src/services/renderer/webgl-utils.ts`. Don't add a new rendering path somewhere else; update that function if needed. + +## Writing fragment shaders + +Shaders live in `apps/web/src/lib/effects/definitions/`. The shared vertex shader (`effect.vert.glsl`) maps clip space to UV coordinates — don't replace it unless you have a specific reason. + +Available uniforms (automatically injected, no need to pass them manually): +- `u_texture` — the input texture (sampler2D) +- `u_resolution` — canvas size in pixels (vec2) + +Any additional uniforms come from the `uniforms()` function in the pass definition. + +**Sampling density — the most common mistake** + +Always use a step of 1 texel when sampling neighbors. Do not scale the step size with the blur radius or intensity — it creates visible discrete artifacts (ghosting/glow look) because there are large gaps between samples that the GPU fills with linear interpolation instead of your intended curve. + +```glsl +// correct — step is always 1 texel, loop count controls radius +for (int i = -30; i <= 30; i++) { + color += texture2D(u_texture, v_texCoord + texelSize * u_direction * float(i)) * weight; +} + +// wrong — stepping 6 texels at a time looks ghosty at high intensity +vec2 offset = texelSize * u_direction * u_radius; +color += texture2D(u_texture, v_texCoord + offset * 2.0) * someWeight; +``` + +If you need a large radius with a fixed kernel size, increase the number of samples rather than the step. + +## Y-flip and coordinate systems + +Source textures (uploaded from canvas) are Y-flipped via `UNPACK_FLIP_Y_WEBGL`. Intermediate FBO textures (rendered by WebGL between passes) are not. In practice this cancels out correctly as long as you use the shared vertex shader — it maps clip space Y consistently so both texture types sample correctly. + +If you write a custom vertex shader or do manual coordinate math, be aware that canvas and WebGL have opposite Y origins (canvas: top-left, WebGL: bottom-left). Getting this wrong produces an upside-down result with no obvious error. diff --git a/package.json b/package.json index 213b6870..521b90b6 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,33 @@ { - "name": "opencut", - "packageManager": "bun@1.2.18", - "workspaces": [ - "apps/*", - "packages/*" - ], - "scripts": { - "dev:web": "turbo run dev --filter=@opencut/web", - "build:web": "turbo run build --filter=@opencut/web", - "dev:tools": "turbo run dev --filter=@opencut/tools", - "build:tools": "turbo run build --filter=@opencut/tools", - "start:tools": "turbo run start --filter=@opencut/tools", - "lint:web": "biome lint apps/web/src --max-diagnostics=1000", - "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000", - "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", - "test": "bun test", - "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts" - }, - "dependencies": { - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", - "better-auth": "^1.4.15", - "next": "^16.1.3" - }, - "devDependencies": { - "turbo": "^2.7.5", - "typescript": "5.8.3" - }, - "trustedDependencies": [ - "@tailwindcss/oxide" - ] + "name": "opencut", + "packageManager": "bun@1.2.18", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev:web": "turbo run dev --filter=@opencut/web", + "build:web": "turbo run build --filter=@opencut/web", + "dev:tools": "turbo run dev --filter=@opencut/tools", + "build:tools": "turbo run build --filter=@opencut/tools", + "start:tools": "turbo run start --filter=@opencut/tools", + "lint:web": "biome lint apps/web/src --max-diagnostics=1000", + "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000", + "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", + "test": "bun test", + "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts" + }, + "dependencies": { + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "better-auth": "^1.4.15", + "next": "^16.1.3" + }, + "devDependencies": { + "turbo": "^2.7.5", + "typescript": "5.8.3" + }, + "trustedDependencies": [ + "@tailwindcss/oxide" + ] } From fe964c4a4a1ead39227b506f28f044f6e914f09e Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 28 Feb 2026 23:48:33 +0100 Subject: [PATCH 20/24] cursor: design skill --- .cursor/skills/design/SKILL.md | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .cursor/skills/design/SKILL.md diff --git a/.cursor/skills/design/SKILL.md b/.cursor/skills/design/SKILL.md new file mode 100644 index 00000000..d5a05e27 --- /dev/null +++ b/.cursor/skills/design/SKILL.md @@ -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. From 85f41756d18d586377823eaac26117f3c8ccb9ba Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 28 Feb 2026 23:58:54 +0100 Subject: [PATCH 21/24] feat: changelog page --- .gitignore | 3 + apps/web/content-collections.ts | 32 +++++ apps/web/content/changelog/0.1.0.md | 35 +++++ apps/web/content/changelog/0.2.0.md | 13 ++ apps/web/next.config.ts | 3 +- apps/web/package.json | 2 + apps/web/src/app/changelog/page.tsx | 142 +++++++++++++++++++ apps/web/tsconfig.json | 3 +- bun.lock | 212 ++++++++++++++++++++++++---- 9 files changed, 415 insertions(+), 30 deletions(-) create mode 100644 apps/web/content-collections.ts create mode 100644 apps/web/content/changelog/0.1.0.md create mode 100644 apps/web/content/changelog/0.2.0.md create mode 100644 apps/web/src/app/changelog/page.tsx diff --git a/.gitignore b/.gitignore index e2b17bce..fa3b5f68 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,8 @@ node_modules # cursor bun.lockb +# content-collections +.content-collections + # Twiggy .cursor/rules/file-structure.mdc diff --git a/apps/web/content-collections.ts b/apps/web/content-collections.ts new file mode 100644 index 00000000..b3028846 --- /dev/null +++ b/apps/web/content-collections.ts @@ -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], +}); diff --git a/apps/web/content/changelog/0.1.0.md b/apps/web/content/changelog/0.1.0.md new file mode 100644 index 00000000..d1c21bf0 --- /dev/null +++ b/apps/web/content/changelog/0.1.0.md @@ -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." +--- diff --git a/apps/web/content/changelog/0.2.0.md b/apps/web/content/changelog/0.2.0.md new file mode 100644 index 00000000..ec3a99e2 --- /dev/null +++ b/apps/web/content/changelog/0.2.0.md @@ -0,0 +1,13 @@ +--- +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." +--- diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index c3676f0f..2f509d2a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,5 +1,6 @@ import type { NextConfig } from "next"; import { withBotId } from "botid/next/config"; +import { withContentCollections } from "@content-collections/next"; const nextConfig: NextConfig = { turbopack: { @@ -54,4 +55,4 @@ const nextConfig: NextConfig = { }, }; -export default withBotId(nextConfig); +export default withContentCollections(withBotId(nextConfig)); diff --git a/apps/web/package.json b/apps/web/package.json index 90aa5d3b..8094c548 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -87,6 +87,8 @@ "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", diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx new file mode 100644 index 00000000..a9072485 --- /dev/null +++ b/apps/web/src/app/changelog/page.tsx @@ -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 = { + 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 ( + +
+
+
+ +
+ {releases.map((release, releaseIndex) => ( +
+ + {releaseIndex < releases.length - 1 && ( + // ml-1.5 aligns with the center of the 11px timeline dot + + )} +
+ ))} +
+
+
+ + ); +} + +type Change = { type: string; text: string }; +type Release = (typeof allChangelogs)[number]; + +function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const grouped = changes.reduce>((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 ( +
+
+
+
+ +
+
+ + {release.version} - {release.date} + +
+ +
+

{release.title}

+ {release.description && ( +

+ {release.description} +

+ )} +
+ +
+ {orderedTypes.map((type) => ( +
+

+ {getSectionTitle(type)}: +

+
    + {groupedChanges[type].map((change) => ( +
  • + {change.text} +
  • + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a8b420f7..3573338a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "content-collections": ["./.content-collections/generated"] }, "forceConsistentCasingInFileNames": true }, diff --git a/bun.lock b/bun.lock index 371fa28f..5e76ed74 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,8 @@ "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", @@ -153,6 +155,12 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@content-collections/core": ["@content-collections/core@0.14.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "camelcase": "^8.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.11", "gray-matter": "^4.0.3", "p-limit": "^6.1.0", "picomatch": "^4.0.2", "pluralize": "^8.0.0", "serialize-javascript": "^6.0.2", "tinyglobby": "^0.2.5", "yaml": "^2.4.5" }, "peerDependencies": { "typescript": "^5.0.2" } }, "sha512-mfviKnONzVcr1D3uasZFEk0E9N4a7liCra06imQrmKF07nGDuvrRKUFoUwUc2IGrW3EYqKF/KV06zfJRzNe3sg=="], + + "@content-collections/integrations": ["@content-collections/integrations@0.5.0", "", { "peerDependencies": { "@content-collections/core": "0.x" } }, "sha512-1en7r518sct0Y8CQ5IsuuBN4uAmtNLaWuxmseW43OxeXyj43Uu2aPBfbopjL4b5xH8WZBdDrrPmikgOl42U46A=="], + + "@content-collections/next": ["@content-collections/next@0.2.11", "", { "dependencies": { "@content-collections/integrations": "0.5.0" }, "peerDependencies": { "@content-collections/core": "0.x", "next": "^12 || ^13 || ^14 || ^15 || ^16" } }, "sha512-/JV+QEXjsWRD5Qn9uBiMUB/2ml66wCRllG6PuJUpOH7PMT4cy/Qk5UnIEpfjdINF6rxfXjUtv8z9Q8QWxR6ZQg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -161,57 +169,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@ffmpeg/core": ["@ffmpeg/core@0.12.10", "", {}, "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA=="], @@ -645,6 +653,8 @@ "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], @@ -669,6 +679,8 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -681,6 +693,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], @@ -791,7 +805,7 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -801,6 +815,8 @@ "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], @@ -813,6 +829,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], @@ -821,6 +839,8 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="], "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], @@ -845,6 +865,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -891,6 +913,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], @@ -905,6 +929,8 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -913,6 +939,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], "libphonenumber-js": ["libphonenumber-js@1.12.10", "", {}, "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ=="], @@ -1065,6 +1093,8 @@ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -1089,8 +1119,12 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], @@ -1153,6 +1187,8 @@ "react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], @@ -1189,6 +1225,8 @@ "schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -1217,10 +1255,12 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], @@ -1245,6 +1285,8 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1329,6 +1371,10 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="], @@ -1459,6 +1505,8 @@ "dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "drizzle-kit/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], + "enhanced-resolve/tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], @@ -1485,8 +1533,12 @@ "recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "tsx/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], + "vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], "webpack/enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], @@ -1617,12 +1669,116 @@ "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + + "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + + "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], From f3af18e0c785a64569df3c6f0e7741e9b3e83e59 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 1 Mar 2026 01:08:26 +0100 Subject: [PATCH 22/24] delete CHANGELOG.md --- CHANGELOG.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 4ddf2a88..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,41 +0,0 @@ -## **0.2.0**: - -This release adds the foundation for motion and effects. - -**Features**: - -- Keyframe animation system, starting with transform properties (position, scale, rotation). -- Effects system (with our first effect: Blur!) - -**Bug fixes**: - -- Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below - -**Improvements**: - -## **0.1.0**: - -This first release focuses on making editing faster, clearer, and more reliable for day-to-day use. - -**Features**: - -- Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in. -- 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. -- New color picker with eyedropper, opacity control, and multiple color formats. -- Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly. -- Move, scale, and rotate elements directly in the preview panel. -- Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now. -- Paste images, videos, or audio from your clipboard straight into the editor. -- Hold `Shift` while moving or resizing to temporarily turn off snapping. - -**Improvements**: - -- Expanded font selection from 7 to over 1,000. -- Fonts load much faster. -- All asset panel tabs now share a consistent layout. -- Text rendering properly supports multiple lines, line height, and letter spacing. -- Better contrast in the dark theme. - -**Bug fixes**: - -- Fixed undo/redo for drag-and-drop so dropped items are reliably undoable. From c6bffaa243795ce47273dc60ee98abaf272d2d35 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 1 Mar 2026 01:10:56 +0100 Subject: [PATCH 23/24] fix(audio): stabilize playback startup when timeline selection is heavy --- apps/web/content/changelog/0.2.0.md | 2 + apps/web/src/core/managers/audio-manager.ts | 42 +++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/web/content/changelog/0.2.0.md b/apps/web/content/changelog/0.2.0.md index ec3a99e2..0539be78 100644 --- a/apps/web/content/changelog/0.2.0.md +++ b/apps/web/content/changelog/0.2.0.md @@ -10,4 +10,6 @@ changes: 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." --- diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 457e49d6..20b124b6 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -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; } } From 354f4dec67ef7eb39d34fca941ca34025a68136b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 1 Mar 2026 01:12:57 +0100 Subject: [PATCH 24/24] changelog: update titles for no title case --- apps/web/content/changelog/0.1.0.md | 2 +- apps/web/content/changelog/0.2.0.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/content/changelog/0.1.0.md b/apps/web/content/changelog/0.1.0.md index d1c21bf0..08a0a9ed 100644 --- a/apps/web/content/changelog/0.1.0.md +++ b/apps/web/content/changelog/0.1.0.md @@ -1,7 +1,7 @@ --- version: "0.1.0" date: "2026-02-23" -title: "Editor Foundation" +title: "Editor foundation" description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use." changes: - type: new diff --git a/apps/web/content/changelog/0.2.0.md b/apps/web/content/changelog/0.2.0.md index 0539be78..ea69f78b 100644 --- a/apps/web/content/changelog/0.2.0.md +++ b/apps/web/content/changelog/0.2.0.md @@ -1,7 +1,7 @@ --- version: "0.2.0" date: "2026-02-29" -title: "Motion & Effects" +title: "Motion & effects" description: "This release adds the foundation for motion and effects." changes: - type: new